hexdoc.patchouli
1__all__ = [ 2 "Book", 3 "BookContext", 4 "Category", 5 "Entry", 6 "FormatTree", 7 "FormattingContext", 8 "page", 9] 10 11from . import page 12from .book import Book 13from .book_context import BookContext 14from .category import Category 15from .entry import Entry 16from .text import FormattingContext, FormatTree
27class Book(HexdocModel): 28 """Main Patchouli book class. 29 30 Includes all data from book.json, categories/entries/pages, and i18n. 31 32 You should probably not use this (or any other Patchouli types, eg. Category, Entry) 33 to edit and re-serialize book.json, because this class sets all the default values 34 as defined by the docs. 35 36 See: https://vazkiimods.github.io/Patchouli/docs/reference/book-json 37 """ 38 39 # not in book.json 40 _categories: dict[ResourceLocation, Category] = PrivateAttr(default_factory=dict) 41 _all_entries: dict[ResourceLocation, Entry] = PrivateAttr(default_factory=dict) 42 43 # required 44 name: LocalizedStr 45 landing_text: FormatTree 46 47 # required in 1.20 but optional in 1.19 48 # so we'll make it optional and validate later 49 use_resource_pack: AtLeast_1_20[Literal[True]] | Before_1_20[bool] = False 50 51 # optional 52 book_texture: ResourceLocation = ResLoc("patchouli", "textures/gui/book_brown.png") 53 filler_texture: ResourceLocation | None = None 54 crafting_texture: ResourceLocation | None = None 55 model: ResourceLocation = ResLoc("patchouli", "book_brown") 56 text_color: Color = Color("000000") 57 header_color: Color = Color("333333") 58 nameplate_color: Color = Color("FFDD00") 59 link_color: Color = Color("0000EE") 60 link_hover_color: Color = Color("8800EE") 61 progress_bar_color: Color = Color("FFFF55") 62 progress_bar_background: Color = Color("DDDDDD") 63 open_sound: ResourceLocation | None = None 64 flip_sound: ResourceLocation | None = None 65 index_icon: ResourceLocation | None = None 66 pamphlet: bool = False 67 show_progress: bool = True 68 version: str | int = 0 69 subtitle: LocalizedStr | None = None 70 creative_tab: str | None = None 71 advancements_tab: str | None = None 72 dont_generate_book: bool = False 73 custom_book_item: ItemStack | None = None 74 show_toasts: bool = True 75 use_blocky_font: bool = False 76 i18n: bool = False 77 macros: dict[str, str] = Field(default_factory=dict) 78 pause_game: bool = False 79 text_overflow_mode: Literal["overflow", "resize", "truncate"] | None = None 80 81 @property 82 def categories(self): 83 return self._categories 84 85 @property 86 def all_entries(self): 87 """ 88 Note: includes external entries as well (not just internal ones) 89 """ 90 return self._all_entries 91 92 def _load_categories(self, context: ContextSource, book_ctx: BookContext): 93 categories = Category.load_all( 94 cast_context(context), 95 book_ctx.book_id, 96 self.use_resource_pack, 97 ) 98 99 if not categories: 100 raise ValueError( 101 "No categories found, are the paths in your properties file correct?" 102 ) 103 104 for id, category in categories.items(): 105 self._categories[id] = category 106 107 link_base = book_ctx.get_link_base(category.resource_dir) 108 book_ctx.book_links[category.book_link_key] = link_base.with_fragment( 109 category.fragment 110 ) 111 112 def _load_entries( 113 self, 114 context: ContextSource, 115 book_ctx: BookContext, 116 loader: ModResourceLoader, 117 ): 118 internal_entries = defaultdict[ResLoc, dict[ResLoc, Entry]](dict) 119 spoilered_categories = dict[ResLoc, bool]() 120 121 for resource_dir, id, data in loader.load_book_assets( 122 parent_book_id=book_ctx.book_id, 123 folder="entries", 124 use_resource_pack=self.use_resource_pack, 125 ): 126 entry = Entry.load(resource_dir, id, data, cast_context(context)) 127 if not entry.is_flag_enabled: 128 logger.info(f"Skipping entry {id} due to disabled flag {entry.flag}") 129 continue 130 131 spoilered_categories[entry.category_id] = ( 132 entry.is_spoiler and spoilered_categories.get(entry.category_id, True) 133 ) 134 135 # i used the entry to insert the entry (pretty sure thanos said that) 136 if resource_dir.internal: 137 internal_entries[entry.category_id][entry.id] = entry 138 self._all_entries[entry.id] = entry 139 140 link_base = book_ctx.get_link_base(resource_dir) 141 book_ctx.book_links[entry.book_link_key] = link_base.with_fragment( 142 entry.fragment 143 ) 144 145 for page in entry.pages: 146 page_key = page.book_link_key(entry.book_link_key) 147 page_fragment = page.fragment(entry.fragment) 148 if page_key is not None and page_fragment is not None: 149 book_ctx.book_links[page_key] = link_base.with_fragment( 150 page_fragment 151 ) 152 153 if not internal_entries: 154 raise ValueError( 155 f"No internal entries found for book {book_ctx.book_id}, is this the correct id?" 156 ) 157 158 for category_id, new_entries in internal_entries.items(): 159 category = self._categories[category_id] 160 category.entries = sorted_dict(category.entries | new_entries) 161 if is_spoiler := spoilered_categories.get(category.id): 162 category.is_spoiler = is_spoiler 163 164 @model_validator(mode="before") 165 @classmethod 166 def _pre_root(cls, data: dict[Any, Any] | Any): 167 if isinstance(data, dict) and "index_icon" not in data: 168 data["index_icon"] = data.get("model") 169 return data 170 171 @model_validator(mode="after") 172 def _post_root(self, info: ValidationInfo): 173 if not info.context: 174 return self 175 176 # make the macros accessible when rendering the template 177 self.macros |= FormattingContext.of(info).macros 178 179 return self
Main Patchouli book class.
Includes all data from book.json, categories/entries/pages, and i18n.
You should probably not use this (or any other Patchouli types, eg. Category, Entry) to edit and re-serialize book.json, because this class sets all the default values as defined by the docs.
See: https://vazkiimods.github.io/Patchouli/docs/reference/book-json
85 @property 86 def all_entries(self): 87 """ 88 Note: includes external entries as well (not just internal ones) 89 """ 90 return self._all_entries
Note: includes external entries as well (not just internal ones)
358def init_private_attributes(self: BaseModel, context: Any, /) -> None: 359 """This function is meant to behave like a BaseModel method to initialise private attributes. 360 361 It takes context as an argument since that's what pydantic-core passes when calling it. 362 363 Args: 364 self: The BaseModel instance. 365 context: The context. 366 """ 367 if getattr(self, '__pydantic_private__', None) is None: 368 pydantic_private = {} 369 for name, private_attr in self.__private_attributes__.items(): 370 default = private_attr.get_default() 371 if default is not PydanticUndefined: 372 pydantic_private[name] = default 373 object_setattr(self, '__pydantic_private__', pydantic_private)
This function is meant to behave like a BaseModel method to initialise private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
12class BookContext(ValidationContextModel): 13 modid: str 14 book_id: ResourceLocation 15 book_links: BookLinks = Field(default_factory=dict) 16 spoilered_advancements: set[ResourceLocation] 17 all_metadata: dict[str, HexdocMetadata] 18 flags: dict[str, bool] 19 20 def get_link_base(self, resource_dir: PathResourceDir) -> URL: 21 modid = resource_dir.modid 22 if resource_dir.internal or modid is None or modid == self.modid: 23 return URL() 24 25 book_url = self.all_metadata[modid].book_url 26 if book_url is None: 27 raise ValueError(f"Mod {modid} does not export a book url") 28 29 return book_url
Base class for all Pydantic models in hexdoc.
Sets the default model config, and overrides __init__ to allow using the
init_context context manager to set validation context for constructors.
20 def get_link_base(self, resource_dir: PathResourceDir) -> URL: 21 modid = resource_dir.modid 22 if resource_dir.internal or modid is None or modid == self.modid: 23 return URL() 24 25 book_url = self.all_metadata[modid].book_url 26 if book_url is None: 27 raise ValueError(f"Mod {modid} does not export a book url") 28 29 return book_url
23class Category(IDModel, Sortable, Flagged): 24 """Category with pages and localizations. 25 26 See: https://vazkiimods.github.io/Patchouli/docs/reference/category-json 27 """ 28 29 entries: SkipJsonSchema[dict[ResourceLocation, Entry]] = Field(default_factory=dict) 30 is_spoiler: SkipJsonSchema[bool] = False 31 32 # required 33 name: LocalizedStr 34 description: FormatTree 35 icon: ItemWithTexture | NamedTexture 36 37 # optional 38 parent_id: ResourceLocation | None = Field(default=None, alias="parent") 39 _parent_cmp_key: tuple[int, ...] | None = None 40 sortnum: int = 0 41 secret: bool = False 42 43 @classmethod 44 def load_all( 45 cls, 46 context: dict[str, Any], 47 book_id: ResourceLocation, 48 use_resource_pack: bool, 49 ) -> dict[ResourceLocation, Self]: 50 loader = ModResourceLoader.of(context) 51 52 # load 53 categories = dict[ResourceLocation, Self]() 54 G = TypedDiGraph[ResourceLocation]() 55 56 for resource_dir, id, data in loader.load_book_assets( 57 book_id, 58 "categories", 59 use_resource_pack, 60 ): 61 # Patchouli checks flags before resolving category parents 62 # https://github.com/VazkiiMods/Patchouli/blob/abd6d03a08c37bcf116730021fda9f477412b31f/Xplat/src/main/java/vazkii/patchouli/client/book/BookContentsBuilder.java#L151 63 category = cls.load(resource_dir, id, data, context) 64 if not category.is_flag_enabled: 65 logger.info( 66 f"Skipping category {id} due to disabled flag {category.flag}" 67 ) 68 continue 69 70 categories[id] = category 71 if category.parent_id: 72 G.add_edge(category.parent_id, category.id) 73 74 # if there's a cycle in the graph, we can't find a valid ordering 75 # eg. two categories with each other as parents 76 if cycle := G.find_cycle(): 77 raise ValueError( 78 "Found cycle of category parents:\n " 79 + "\n ".join(f"{u} -> {v}" for u, v in cycle) 80 ) 81 82 # late-init _parent_cmp_key 83 for parent_id in G.topological_sort(): 84 parent = categories.get(parent_id) 85 if parent is None: 86 children = ", ".join(str(v) for _, v in G.iter_out_edges(parent_id)) 87 raise ValueError( 88 f"Parent category {parent_id} required by {children} does not exist" 89 ) 90 91 for _, child_id in G.iter_out_edges(parent_id): 92 categories[child_id]._parent_cmp_key = parent._cmp_key 93 94 # return sorted by sortnum, which requires parent to be initialized 95 return sorted_dict(categories) 96 97 @property 98 def book_link_key(self): 99 """Key to look up this category in `BookContext.book_links`.""" 100 return str(self.id) 101 102 @property 103 def fragment(self): 104 """URL fragment for this category in `BookContext.book_links`.""" 105 return self.id.path 106 107 @property 108 def redirect_path(self): 109 """Path to this category when generating redirect pages.""" 110 return self.id.path 111 112 @property 113 def _is_cmp_key_ready(self) -> bool: 114 return self.parent_id is None or self._parent_cmp_key is not None 115 116 @property 117 def _cmp_key(self) -> tuple[int, ...]: 118 # implement Sortable 119 if parent_cmp_key := self._parent_cmp_key: 120 return parent_cmp_key + (self.sortnum,) 121 return (self.sortnum,)
Category with pages and localizations.
See: https://vazkiimods.github.io/Patchouli/docs/reference/category-json
43 @classmethod 44 def load_all( 45 cls, 46 context: dict[str, Any], 47 book_id: ResourceLocation, 48 use_resource_pack: bool, 49 ) -> dict[ResourceLocation, Self]: 50 loader = ModResourceLoader.of(context) 51 52 # load 53 categories = dict[ResourceLocation, Self]() 54 G = TypedDiGraph[ResourceLocation]() 55 56 for resource_dir, id, data in loader.load_book_assets( 57 book_id, 58 "categories", 59 use_resource_pack, 60 ): 61 # Patchouli checks flags before resolving category parents 62 # https://github.com/VazkiiMods/Patchouli/blob/abd6d03a08c37bcf116730021fda9f477412b31f/Xplat/src/main/java/vazkii/patchouli/client/book/BookContentsBuilder.java#L151 63 category = cls.load(resource_dir, id, data, context) 64 if not category.is_flag_enabled: 65 logger.info( 66 f"Skipping category {id} due to disabled flag {category.flag}" 67 ) 68 continue 69 70 categories[id] = category 71 if category.parent_id: 72 G.add_edge(category.parent_id, category.id) 73 74 # if there's a cycle in the graph, we can't find a valid ordering 75 # eg. two categories with each other as parents 76 if cycle := G.find_cycle(): 77 raise ValueError( 78 "Found cycle of category parents:\n " 79 + "\n ".join(f"{u} -> {v}" for u, v in cycle) 80 ) 81 82 # late-init _parent_cmp_key 83 for parent_id in G.topological_sort(): 84 parent = categories.get(parent_id) 85 if parent is None: 86 children = ", ".join(str(v) for _, v in G.iter_out_edges(parent_id)) 87 raise ValueError( 88 f"Parent category {parent_id} required by {children} does not exist" 89 ) 90 91 for _, child_id in G.iter_out_edges(parent_id): 92 categories[child_id]._parent_cmp_key = parent._cmp_key 93 94 # return sorted by sortnum, which requires parent to be initialized 95 return sorted_dict(categories)
97 @property 98 def book_link_key(self): 99 """Key to look up this category in `BookContext.book_links`.""" 100 return str(self.id)
Key to look up this category in BookContext.book_links.
102 @property 103 def fragment(self): 104 """URL fragment for this category in `BookContext.book_links`.""" 105 return self.id.path
URL fragment for this category in BookContext.book_links.
107 @property 108 def redirect_path(self): 109 """Path to this category when generating redirect pages.""" 110 return self.id.path
Path to this category when generating redirect pages.
358def init_private_attributes(self: BaseModel, context: Any, /) -> None: 359 """This function is meant to behave like a BaseModel method to initialise private attributes. 360 361 It takes context as an argument since that's what pydantic-core passes when calling it. 362 363 Args: 364 self: The BaseModel instance. 365 context: The context. 366 """ 367 if getattr(self, '__pydantic_private__', None) is None: 368 pydantic_private = {} 369 for name, private_attr in self.__private_attributes__.items(): 370 default = private_attr.get_default() 371 if default is not PydanticUndefined: 372 pydantic_private[name] = default 373 object_setattr(self, '__pydantic_private__', pydantic_private)
This function is meant to behave like a BaseModel method to initialise private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
20class Entry(IDModel, Sortable, AdvancementSpoilered, Flagged): 21 """Entry json file, with pages and localizations. 22 23 See: https://vazkiimods.github.io/Patchouli/docs/reference/entry-json 24 """ 25 26 # required (entry.json) 27 name: LocalizedStr 28 category_id: ResourceLocation = Field(alias="category") 29 icon: ItemWithTexture | NamedTexture 30 pages: list[Page] 31 32 # optional (entry.json) 33 advancement: ResourceLocation | None = None 34 priority: bool = False 35 secret: bool = False 36 read_by_default: bool = False 37 sortnum: int = 0 38 turnin: ResourceLocation | None = None 39 extra_recipe_mappings: dict[ItemStack, int] | None = None 40 entry_color: Color | None = None # this is undocumented lmao 41 42 @property 43 def _cmp_key(self) -> tuple[bool, int, LocalizedStr]: 44 # implement Sortable 45 # note: python sorts false before true, so we invert priority 46 return (not self.priority, self.sortnum, self.name) 47 48 @property 49 def anchors(self) -> Iterable[str]: 50 for page in self.pages: 51 if page.anchor is not None: 52 yield page.anchor 53 54 @property 55 def book_link_key(self): 56 """Key to look up this entry in `BookContext.book_links`.""" 57 return str(self.id) 58 59 @property 60 def fragment(self): 61 """URL fragment for this entry in `BookContext.book_links`.""" 62 return self.id.path 63 64 @property 65 def redirect_path(self): 66 """Path to this entry when generating redirect pages.""" 67 return self.id.path 68 69 @property 70 def first_text_page(self): 71 for page in self.pages: 72 if getattr(page, "text", None): 73 return page 74 75 def preprocess_pages(self) -> Iterator[Page]: 76 """Combines adjacent PageWithAccumulator recipes as much as possible.""" 77 acc: AccumulatorPage[Any] | None = None 78 79 for page in self.pages: 80 if isinstance(page, PageWithAccumulator): 81 if not (acc and acc.can_append(page)): 82 if acc and acc.has_content: 83 yield acc 84 acc = page.accumulator_type().from_page(page) 85 86 acc.append(page) 87 88 if not acc.can_append_more: 89 yield acc 90 acc = None 91 else: 92 if acc and acc.has_content: 93 yield acc 94 acc = None 95 yield page 96 97 if acc and acc.has_content: 98 yield acc 99 100 def _get_advancement(self): 101 # implements AdvancementSpoilered 102 return self.advancement 103 104 @model_validator(mode="after") 105 def _skip_disabled_pages(self): 106 new_pages = list[Page]() 107 for i, page in enumerate(self.pages): 108 if not page.is_flag_enabled: 109 logger.info( 110 f"Skipping page {i} of entry {self.id} due to disabled flag {page.flag}" 111 ) 112 continue 113 new_pages.append(page) 114 if not new_pages: 115 logger.warning( 116 f"Entry has no pages{' after applying flags' if self.pages else ''}: {self.id}" 117 ) 118 self.pages = new_pages 119 return self
Entry json file, with pages and localizations.
See: https://vazkiimods.github.io/Patchouli/docs/reference/entry-json
54 @property 55 def book_link_key(self): 56 """Key to look up this entry in `BookContext.book_links`.""" 57 return str(self.id)
Key to look up this entry in BookContext.book_links.
59 @property 60 def fragment(self): 61 """URL fragment for this entry in `BookContext.book_links`.""" 62 return self.id.path
URL fragment for this entry in BookContext.book_links.
64 @property 65 def redirect_path(self): 66 """Path to this entry when generating redirect pages.""" 67 return self.id.path
Path to this entry when generating redirect pages.
75 def preprocess_pages(self) -> Iterator[Page]: 76 """Combines adjacent PageWithAccumulator recipes as much as possible.""" 77 acc: AccumulatorPage[Any] | None = None 78 79 for page in self.pages: 80 if isinstance(page, PageWithAccumulator): 81 if not (acc and acc.can_append(page)): 82 if acc and acc.has_content: 83 yield acc 84 acc = page.accumulator_type().from_page(page) 85 86 acc.append(page) 87 88 if not acc.can_append_more: 89 yield acc 90 acc = None 91 else: 92 if acc and acc.has_content: 93 yield acc 94 acc = None 95 yield page 96 97 if acc and acc.has_content: 98 yield acc
Combines adjacent PageWithAccumulator recipes as much as possible.
358def init_private_attributes(self: BaseModel, context: Any, /) -> None: 359 """This function is meant to behave like a BaseModel method to initialise private attributes. 360 361 It takes context as an argument since that's what pydantic-core passes when calling it. 362 363 Args: 364 self: The BaseModel instance. 365 context: The context. 366 """ 367 if getattr(self, '__pydantic_private__', None) is None: 368 pydantic_private = {} 369 for name, private_attr in self.__private_attributes__.items(): 370 default = private_attr.get_default() 371 if default is not PydanticUndefined: 372 pydantic_private[name] = default 373 object_setattr(self, '__pydantic_private__', pydantic_private)
This function is meant to behave like a BaseModel method to initialise private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
346@final 347@dataclass(config=DEFAULT_CONFIG | json_schema_extra_config(type_str, inherited)) 348class FormatTree: 349 style: Style 350 children: list[FormatTree | str] # this can't be Self, it breaks Pydantic 351 raw: str | None = None 352 353 @classmethod 354 def format( 355 cls, 356 string: str, 357 *, 358 book_id: ResourceLocation, 359 i18n: I18n, 360 macros: dict[str, str], 361 is_0_black: bool, 362 pm: PluginManager, 363 link_overrides: dict[str, str], 364 ) -> Self: 365 for macro, replace in macros.items(): 366 if macro in replace: 367 raise RuntimeError( 368 f"Recursive macro: replacement `{replace}` is matched by key `{macro}`" 369 ) 370 371 working_string = resolve_macros(string, macros) 372 373 # lex out parsed styles 374 text_nodes: list[str] = [] 375 styles: list[Style | _CloseTag] = [] 376 text_since_prev_style: list[str] = [] 377 last_end = 0 378 379 for match in re.finditer(STYLE_REGEX, working_string): 380 # get the text between the previous match and here 381 leading_text = working_string[last_end : match.start()] 382 text_since_prev_style.append(leading_text) 383 last_end = match.end() 384 385 match Style.parse(match[1], book_id, i18n, is_0_black, link_overrides): 386 case str(replacement): 387 # str means "use this instead of the original value" 388 text_since_prev_style.append(replacement) 389 case Style() | _CloseTag() as style: 390 # add this style and collect the text since the previous one 391 styles.append(style) 392 text_nodes.append("".join(text_since_prev_style)) 393 text_since_prev_style.clear() 394 395 text_nodes.append("".join(text_since_prev_style) + working_string[last_end:]) 396 first_node = text_nodes.pop(0) 397 398 # parse 399 style_stack = [ 400 FormatTree(CommandStyle(type=SpecialStyleType.base), []), 401 FormatTree(ParagraphStyle.paragraph(), [first_node]), 402 ] 403 for style, text in zip(styles, text_nodes): 404 tmp_stylestack: list[Style] = [] 405 if style.type == SpecialStyleType.base: 406 while style_stack[-1].style.type != SpecialStyleType.paragraph: 407 last_node = style_stack.pop() 408 style_stack[-1].children.append(last_node) 409 elif any(tree.style.type == style.type for tree in style_stack): 410 while len(style_stack) >= 2: 411 last_node = style_stack.pop() 412 style_stack[-1].children.append(last_node) 413 if last_node.style.type == style.type: 414 break 415 tmp_stylestack.append(last_node.style) 416 417 for sty in tmp_stylestack: 418 style_stack.append(FormatTree(sty, [])) 419 420 if isinstance(style, _CloseTag): 421 if text: 422 style_stack[-1].children.append(text) 423 else: 424 style_stack.append(FormatTree(style, [text] if text else [])) 425 426 while len(style_stack) >= 2: 427 last_node = style_stack.pop() 428 style_stack[-1].children.append(last_node) 429 430 unvalidated_tree = style_stack[0] 431 unvalidated_tree.raw = string 432 433 validated_tree = pm.validate_format_tree( 434 tree=unvalidated_tree, 435 macros=macros, 436 book_id=book_id, 437 i18n=i18n, 438 is_0_black=is_0_black, 439 link_overrides=link_overrides, 440 ) 441 assert isinstance(validated_tree, cls) 442 443 return validated_tree 444 445 @model_validator(mode="wrap") 446 @classmethod 447 def _wrap_root( 448 cls, 449 value: str | LocalizedStr | Self, 450 handler: ModelWrapValidatorHandler[Self], 451 info: ValidationInfo, 452 ): 453 if not info.context or isinstance(value, FormatTree): 454 return handler(value) 455 456 context = FormattingContext.of(info) 457 i18n = I18n.of(info) 458 pm = PluginManager.of(info) 459 props = Properties.of(info) 460 461 if isinstance(value, str): 462 value = i18n.localize(value) 463 464 return cls.format( 465 value.value, 466 book_id=context.book_id, 467 i18n=i18n, 468 macros=context.macros, 469 is_0_black=props.is_0_black, 470 pm=pm, 471 link_overrides=props.link_overrides, 472 )
353 @classmethod 354 def format( 355 cls, 356 string: str, 357 *, 358 book_id: ResourceLocation, 359 i18n: I18n, 360 macros: dict[str, str], 361 is_0_black: bool, 362 pm: PluginManager, 363 link_overrides: dict[str, str], 364 ) -> Self: 365 for macro, replace in macros.items(): 366 if macro in replace: 367 raise RuntimeError( 368 f"Recursive macro: replacement `{replace}` is matched by key `{macro}`" 369 ) 370 371 working_string = resolve_macros(string, macros) 372 373 # lex out parsed styles 374 text_nodes: list[str] = [] 375 styles: list[Style | _CloseTag] = [] 376 text_since_prev_style: list[str] = [] 377 last_end = 0 378 379 for match in re.finditer(STYLE_REGEX, working_string): 380 # get the text between the previous match and here 381 leading_text = working_string[last_end : match.start()] 382 text_since_prev_style.append(leading_text) 383 last_end = match.end() 384 385 match Style.parse(match[1], book_id, i18n, is_0_black, link_overrides): 386 case str(replacement): 387 # str means "use this instead of the original value" 388 text_since_prev_style.append(replacement) 389 case Style() | _CloseTag() as style: 390 # add this style and collect the text since the previous one 391 styles.append(style) 392 text_nodes.append("".join(text_since_prev_style)) 393 text_since_prev_style.clear() 394 395 text_nodes.append("".join(text_since_prev_style) + working_string[last_end:]) 396 first_node = text_nodes.pop(0) 397 398 # parse 399 style_stack = [ 400 FormatTree(CommandStyle(type=SpecialStyleType.base), []), 401 FormatTree(ParagraphStyle.paragraph(), [first_node]), 402 ] 403 for style, text in zip(styles, text_nodes): 404 tmp_stylestack: list[Style] = [] 405 if style.type == SpecialStyleType.base: 406 while style_stack[-1].style.type != SpecialStyleType.paragraph: 407 last_node = style_stack.pop() 408 style_stack[-1].children.append(last_node) 409 elif any(tree.style.type == style.type for tree in style_stack): 410 while len(style_stack) >= 2: 411 last_node = style_stack.pop() 412 style_stack[-1].children.append(last_node) 413 if last_node.style.type == style.type: 414 break 415 tmp_stylestack.append(last_node.style) 416 417 for sty in tmp_stylestack: 418 style_stack.append(FormatTree(sty, [])) 419 420 if isinstance(style, _CloseTag): 421 if text: 422 style_stack[-1].children.append(text) 423 else: 424 style_stack.append(FormatTree(style, [text] if text else [])) 425 426 while len(style_stack) >= 2: 427 last_node = style_stack.pop() 428 style_stack[-1].children.append(last_node) 429 430 unvalidated_tree = style_stack[0] 431 unvalidated_tree.raw = string 432 433 validated_tree = pm.validate_format_tree( 434 tree=unvalidated_tree, 435 macros=macros, 436 book_id=book_id, 437 i18n=i18n, 438 is_0_black=is_0_black, 439 link_overrides=link_overrides, 440 ) 441 assert isinstance(validated_tree, cls) 442 443 return validated_tree
73class FormattingContext(ValidationContextModel): 74 book_id: ResourceLocation 75 macros: dict[str, str]
Base class for all Pydantic models in hexdoc.
Sets the default model config, and overrides __init__ to allow using the
init_context context manager to set validation context for constructors.