From 726a4221c788b9c23eeadc4c1a94c2fcd4e495e2 Mon Sep 17 00:00:00 2001 From: Samuel Letellier-Duchesne Date: Fri, 27 Feb 2026 05:59:19 -0500 Subject: [PATCH 1/3] Add inline field metadata pills to IO Reference pages Parse the EnergyPlus IDD file to extract structured metadata (type, units, default, range, choices, flags) for each IDF object field, then inject inline pill/badge elements after #### Field: headings in the IO Reference. - Add IddField and IddObject dataclasses to models.py - Create idd_parser.py to parse Energy+.idd.in (855 objects) - Thread idd_index through the conversion pipeline (IO Reference only) - Add inject_field_metadata() with pill formatting to postprocessor - Add idf-fields.css with colored pill styling (type=accent, required=orange) - Include idd/ in sparse checkout alongside doc/ Co-Authored-By: Claude Opus 4.6 --- scripts/assets/idf-fields.css | 90 +++++++++++++++ scripts/convert.py | 32 +++++- scripts/convert_all.py | 4 +- scripts/idd_parser.py | 183 ++++++++++++++++++++++++++++++ scripts/markdown_postprocessor.py | 121 +++++++++++++++++++- scripts/models.py | 31 +++++ 6 files changed, 454 insertions(+), 7 deletions(-) create mode 100644 scripts/assets/idf-fields.css create mode 100644 scripts/idd_parser.py diff --git a/scripts/assets/idf-fields.css b/scripts/assets/idf-fields.css new file mode 100644 index 000000000..fe7fbfcc8 --- /dev/null +++ b/scripts/assets/idf-fields.css @@ -0,0 +1,90 @@ +/* Inline pill/badge styling for IDF field metadata. + * Appears directly after #### Field: headings in the IO Reference. + * Metadata is extracted from the EnergyPlus IDD file. + */ + +/* Container */ +.field-pills { + margin: 0.2em 0 0.5em 0; + line-height: 2; +} + +/* Base pill style */ +.field-pill { + display: inline-block; + font-size: 0.75em; + padding: 0.15em 0.55em; + border-radius: 999px; + font-family: var(--md-code-font-family, monospace); + vertical-align: baseline; + white-space: nowrap; +} + +.field-pill .pill-label { + opacity: 0.65; + font-weight: 400; +} + +/* Type pill — primary accent */ +.field-pill.pill-type { + background: var(--md-accent-fg-color); + color: var(--md-accent-bg-color, #fff); + font-weight: 600; +} + +/* Units pill */ +.field-pill.pill-units { + background: var(--md-code-bg-color); + color: var(--md-code-fg-color); + border: 1px solid var(--md-default-fg-color--lightest); +} + +/* Default pill */ +.field-pill.pill-default { + background: var(--md-code-bg-color); + color: var(--md-code-fg-color); + border: 1px solid var(--md-default-fg-color--lightest); +} + +/* Range pill */ +.field-pill.pill-range { + background: var(--md-code-bg-color); + color: var(--md-code-fg-color); + border: 1px solid var(--md-default-fg-color--lightest); +} + +/* Flag pills (Required, Autosizable, etc.) */ +.field-pill.pill-flag { + background: var(--md-code-bg-color); + color: var(--md-default-fg-color--light); + border: 1px solid var(--md-default-fg-color--lightest); + font-style: italic; +} + +.field-pill.pill-required { + background: hsla(15, 80%, 55%, 0.12); + color: hsl(15, 70%, 45%); + border-color: hsla(15, 70%, 55%, 0.3); + font-weight: 600; + font-style: normal; +} + +/* Dark mode override for Required */ +[data-md-color-scheme="slate"] .field-pill.pill-required { + background: hsla(15, 80%, 55%, 0.18); + color: hsl(15, 70%, 65%); + border-color: hsla(15, 70%, 55%, 0.35); +} + +/* Choices row */ +.field-choices { + margin-top: 0.15em; + line-height: 1.9; +} + +.field-choices code.pill-choice { + font-size: 0.8em; + padding: 0.1em 0.4em; + border-radius: 3px; + margin-right: 0.2em; +} diff --git a/scripts/convert.py b/scripts/convert.py index 93372ca48..fcbab0968 100644 --- a/scripts/convert.py +++ b/scripts/convert.py @@ -33,9 +33,10 @@ IMAGE_EXTENSIONS, version_to_title, ) +from scripts.idd_parser import parse_idd from scripts.latex_preprocessor import preprocess from scripts.markdown_postprocessor import postprocess -from scripts.models import ConversionResult, DocSet, DocSetResult, LabelRef, VersionResult +from scripts.models import ConversionResult, DocSet, DocSetResult, IddObject, LabelRef, VersionResult from scripts.nav_generator import extract_heading, generate_nav, parse_input_chain logger = logging.getLogger(__name__) @@ -294,6 +295,7 @@ def convert_tex_file( doc_set_title: str = "", current_md_path: str = "", figure_numbers: list[int] | None = None, + idd_index: dict[str, IddObject] | None = None, ) -> ConversionResult: """Convert a single .tex file to Markdown via preprocessing -> Pandoc -> postprocessing.""" warnings: list[str] = [] @@ -362,6 +364,7 @@ def convert_tex_file( rel_depth=rel_depth, current_md_path=current_md_path, figure_numbers=figure_numbers, + idd_index=idd_index, ) # Write output @@ -462,6 +465,7 @@ def _convert_files( label_index: dict[str, LabelRef], max_workers: int, file_figure_numbers: dict[str, list[int]] | None = None, + idd_index: dict[str, IddObject] | None = None, ) -> list[tuple[str, ConversionResult]]: """Run file conversions, using a thread pool when *max_workers* > 1.""" if file_figure_numbers is None: @@ -480,6 +484,7 @@ def _convert_files( doc_set_title, current_md_path, file_figure_numbers.get(current_md_path), + idd_index, ): inp for inp, tex_path, output_path, rel_depth, current_md_path in tasks } @@ -499,6 +504,7 @@ def _convert_files( doc_set_title=doc_set_title, current_md_path=current_md_path, figure_numbers=file_figure_numbers.get(current_md_path), + idd_index=idd_index, ), )) return converted @@ -538,6 +544,7 @@ def convert_doc_set( *, max_workers: int = 1, file_figure_numbers: dict[str, list[int]] | None = None, + idd_index: dict[str, IddObject] | None = None, ) -> DocSetResult: """Convert all files in a doc set. @@ -553,7 +560,9 @@ def convert_doc_set( tasks = _collect_tasks(inputs, doc_set, output_dir, parent_children, result) # Phase 1: Convert files (parallel when max_workers > 1) - converted = _convert_files(tasks, doc_set.slug, doc_set.title, label_index, max_workers, file_figure_numbers) + converted = _convert_files( + tasks, doc_set.slug, doc_set.title, label_index, max_workers, file_figure_numbers, idd_index + ) # Phase 2: Log results and append TOCs (must happen after files are written) for inp, file_result in converted: @@ -650,7 +659,7 @@ def generate_zensical_config( {"path": "https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.2.2/es5/tex-mml-chtml.js", "async": True}, {"path": "assets/eq-tooltips.js"}, ] - project["extra_css"] = ["assets/eq-tooltips.css"] + project["extra_css"] = ["assets/eq-tooltips.css", "assets/idf-fields.css"] config_path = output_dir / "zensical.toml" config_path.write_text(tomli_w.dumps(config)) @@ -720,11 +729,26 @@ def convert_version( # Build label index across all doc sets label_index, file_figure_numbers = build_label_index(source_dir, doc_sets) + # Parse IDD for structured field metadata (IO Reference only) + idd_index: dict[str, IddObject] | None = None + idd_path = source_dir / "idd" / "Energy+.idd.in" + if idd_path.exists(): + idd_index = parse_idd(idd_path.read_text(errors="replace")) + else: + logger.warning("IDD file not found at %s, field metadata will not be injected", idd_path) + # Convert each doc set for ds in doc_sets: logger.info("Converting doc set: %s", ds.title) + # Only pass IDD index for IO Reference doc set (contains IDF object field docs) + ds_idd = idd_index if ds.slug == "io-reference" else None ds_result = convert_doc_set( - ds, output_dir, label_index, max_workers=max_workers, file_figure_numbers=file_figure_numbers + ds, + output_dir, + label_index, + max_workers=max_workers, + file_figure_numbers=file_figure_numbers, + idd_index=ds_idd, ) result.doc_set_results.append(ds_result) logger.info( diff --git a/scripts/convert_all.py b/scripts/convert_all.py index 9757d65fc..f7ad74ccf 100644 --- a/scripts/convert_all.py +++ b/scripts/convert_all.py @@ -37,7 +37,7 @@ def clone_version(version: str, clone_dir: Path) -> Path: Returns the path to the cloned repo. """ target = clone_dir / version - if target.exists() and (target / "doc").exists(): + if target.exists() and (target / "doc").exists() and (target / "idd").exists(): logger.info("Source for %s already exists, reusing", version) return target @@ -67,7 +67,7 @@ def clone_version(version: str, clone_dir: Path) -> Path: text=True, ) subprocess.run( - ["git", "-C", str(target), "sparse-checkout", "set", "doc"], + ["git", "-C", str(target), "sparse-checkout", "set", "doc", "idd"], check=True, capture_output=True, ) diff --git a/scripts/idd_parser.py b/scripts/idd_parser.py new file mode 100644 index 000000000..4bcced01b --- /dev/null +++ b/scripts/idd_parser.py @@ -0,0 +1,183 @@ +"""Parser for EnergyPlus IDD (Input Data Dictionary) files. + +Extracts structured metadata for each IDF object and its fields, +including types, units, defaults, ranges, choices, and flags. +""" + +from __future__ import annotations + +import logging +import re + +from scripts.models import IddField, IddObject + +logger = logging.getLogger(__name__) + + +def parse_idd(idd_text: str) -> dict[str, IddObject]: + """Parse an Energy+.idd.in file into a dict mapping object names to IddObject. + + Args: + idd_text: Full text content of the IDD file. + + Returns: + Dictionary mapping object names (e.g., "Pump:ConstantSpeed") to their + IddObject definitions with all field metadata. + """ + # Strip comment-only lines (starting with !) but keep inline \-directives + lines = idd_text.splitlines() + cleaned_lines: list[str] = [] + for line in lines: + stripped = line.strip() + if stripped.startswith("!"): + continue + cleaned_lines.append(line) + + text = "\n".join(cleaned_lines) + + # Split into object blocks. Each object ends with a semicolon on its last field. + # Objects are separated by their name line (a line starting with a letter and ending with comma). + objects = _split_objects(text) + + result: dict[str, IddObject] = {} + for obj_text in objects: + obj = _parse_object(obj_text) + if obj: + result[obj.name] = obj + + logger.info("Parsed %d IDD objects", len(result)) + return result + + +def _split_objects(text: str) -> list[str]: + """Split IDD text into individual object definition blocks.""" + # An object starts with a line like "ObjectName," or "Object:SubName," + # at the beginning of a line (no leading whitespace). + # We split on these boundaries. + object_pattern = re.compile(r"^([A-Z][A-Za-z0-9:_\- ]*),\s*$", re.MULTILINE) + + objects: list[str] = [] + matches = list(object_pattern.finditer(text)) + + for i, match in enumerate(matches): + start = match.start() + end = matches[i + 1].start() if i + 1 < len(matches) else len(text) + objects.append(text[start:end].strip()) + + return objects + + +def _parse_object(obj_text: str) -> IddObject | None: + """Parse a single IDD object block into an IddObject.""" + lines = obj_text.split("\n") + if not lines: + return None + + # First line is "ObjectName," + obj_name = lines[0].strip().rstrip(",").strip() + if not obj_name: + return None + + memo_parts: list[str] = [] + fields = _parse_object_fields(lines[1:], memo_parts) + + # Build fields_by_name lookup (case-insensitive on the field name) + fields_by_name: dict[str, IddField] = {f.name.lower(): f for f in fields if f.name} + + return IddObject( + name=obj_name, + memo=" ".join(memo_parts), + fields=fields, + fields_by_name=fields_by_name, + ) + + +def _parse_object_fields(lines: list[str], memo_parts: list[str]) -> list[IddField]: + """Parse field definitions from an object's body lines.""" + fields: list[IddField] = [] + current_field: IddField | None = None + + for line in lines: + stripped = line.strip() + if not stripped: + continue + + # Object-level directives (before first field) + if stripped.startswith("\\") and current_field is None: + _parse_object_directive(stripped, memo_parts) + continue + + # Field definition line: "A1," or "N2," or "A1;" etc. + field_match = re.match(r"^([AN]\d+)\s*[,;]", stripped) + if field_match: + if current_field is not None: + fields.append(current_field) + current_field = IddField(name="", field_id=field_match.group(1)) + inline = stripped[field_match.end() :].strip() + if inline: + _parse_field_directive(inline, current_field) + continue + + # Field-level directives + if stripped.startswith("\\") and current_field is not None: + _parse_field_directive(stripped, current_field) + + if current_field is not None: + fields.append(current_field) + return fields + + +def _parse_object_directive(text: str, memo_parts: list[str]) -> None: + """Parse an object-level directive like \\memo, \\unique-object, etc.""" + if text.startswith("\\memo"): + memo_parts.append(text[len("\\memo") :].strip()) + + +def _parse_field_directive(text: str, field: IddField) -> None: + """Parse a field-level directive and update the IddField.""" + if text.startswith("\\field"): + field.name = text[len("\\field") :].strip() + elif text.startswith("\\type"): + field.field_type = text[len("\\type") :].strip().lower() + elif text.startswith("\\units") and not text.startswith("\\unitsBasedOnField"): + field.units = text[len("\\units") :].strip() + elif text.startswith("\\ip-units"): + field.ip_units = text[len("\\ip-units") :].strip() + elif text.startswith("\\default"): + field.default = text[len("\\default") :].strip() + elif text.startswith("\\minimum"): + _parse_bound_directive(text, "\\minimum", field, is_min=True) + elif text.startswith("\\maximum"): + _parse_bound_directive(text, "\\maximum", field, is_min=False) + else: + _parse_field_flag_directive(text, field) + + +def _parse_bound_directive(text: str, prefix: str, field: IddField, *, is_min: bool) -> None: + """Parse \\minimum or \\maximum directives, including exclusive variants (> or <).""" + exclusive = len(text) > len(prefix) and text[len(prefix)] in "<>" + directive = prefix + text[len(prefix)] if exclusive else prefix + value = text[len(directive) :].strip() + if is_min: + field.minimum = value + field.minimum_exclusive = exclusive + else: + field.maximum = value + field.maximum_exclusive = exclusive + + +def _parse_field_flag_directive(text: str, field: IddField) -> None: + """Parse flag and list directives (required, autosizable, key, note, etc.).""" + if text.startswith("\\required-field"): + field.required = True + elif text.startswith("\\autosizable"): + field.autosizable = True + elif text.startswith("\\autocalculatable"): + field.autocalculatable = True + elif text.startswith("\\key"): + key_value = text[len("\\key") :].strip() + if key_value: + field.keys.append(key_value) + elif text.startswith("\\note"): + note_text = text[len("\\note") :].strip() + field.notes = f"{field.notes} {note_text}" if field.notes else note_text diff --git a/scripts/markdown_postprocessor.py b/scripts/markdown_postprocessor.py index 2e7b0572b..92fae90ba 100644 --- a/scripts/markdown_postprocessor.py +++ b/scripts/markdown_postprocessor.py @@ -18,7 +18,7 @@ import posixpath import re -from scripts.models import LabelRef +from scripts.models import IddField, IddObject, LabelRef # Map PDF filenames used in \href{...} to their doc-set URL slugs. # These are inter-doc-set cross-references left over from the original @@ -394,6 +394,122 @@ def clean_div_wrappers(text: str) -> str: return "\n".join(result) +_IDD_TYPE_LABELS = { + "real": "Real", + "integer": "Integer", + "alpha": "Alpha", + "choice": "Choice", + "node": "Node", + "object-list": "Object-List", + "external-list": "External-List", +} + + +def _pill(css_class: str, label: str, value: str = "") -> str: + """Build an HTML span pill/badge.""" + if value: + return f'{label} {value}' + return f'{label}' + + +def _format_range(field: IddField) -> str: + """Format min/max range constraints as a readable string.""" + parts: list[str] = [] + if field.minimum: + op = ">" if field.minimum_exclusive else "\u2265" # > or ≥ + parts.append(f"{op}\u2009{field.minimum}") + if field.maximum: + op = "<" if field.maximum_exclusive else "\u2264" # < or ≤ + parts.append(f"{op}\u2009{field.maximum}") + return ", ".join(parts) + + +def _format_field_attrs(field: IddField) -> str: + """Build inline pill/badge HTML for a field's metadata.""" + pills: list[str] = [] + + if field.field_type: + type_label = _IDD_TYPE_LABELS.get(field.field_type, field.field_type.title()) + pills.append(_pill("pill-type", type_label)) + + if field.units: + unit_str = f"{field.units} / {field.ip_units}" if field.ip_units else field.units + pills.append(_pill("pill-units", unit_str)) + + if field.default: + pills.append(_pill("pill-default", "Default:", field.default)) + + range_str = _format_range(field) + if range_str: + pills.append(_pill("pill-range", "Range:", range_str)) + + if field.required: + pills.append(_pill("pill-flag pill-required", "Required")) + if field.autosizable: + pills.append(_pill("pill-flag", "Autosizable")) + if field.autocalculatable: + pills.append(_pill("pill-flag", "Autocalculatable")) + + if not pills: + return "" + + lines = ['
'] + lines.append(" ".join(pills)) + + # Choices get their own line since they can be long + if field.keys: + choice_spans = " ".join(f'{k}' for k in field.keys) + lines.append(f'
{choice_spans}
') + + lines.append("
") + return "\n".join(lines) + + +def inject_field_metadata(text: str, idd_index: dict[str, IddObject]) -> str: + """Inject structured metadata blocks after #### Field: headings using IDD data. + + Scans the markdown for heading patterns to determine the current IDF object, + then looks up field metadata from the IDD index and inserts styled attribute + blocks after each field heading. + """ + lines = text.split("\n") + result: list[str] = [] + current_object: IddObject | None = None + + # Heading patterns + # Object headings: # ObjectName or ## ObjectName (for group pages) + h1_pattern = re.compile(r"^(#{1,3})\s+(.+)$") + # Field headings: #### Field: FieldName + field_pattern = re.compile(r"^####\s+Field:\s*(.+)$") + + for line in lines: + result.append(line) + + # Check for object-level headings (h1, h2, h3) that match an IDD object + h_match = h1_pattern.match(line) + if h_match: + heading_text = h_match.group(2).strip() + # Strip any Pandoc attributes like {#id .class} + heading_text = re.sub(r"\s*\{[#.][^}]*\}", "", heading_text).strip() + if heading_text in idd_index: + current_object = idd_index[heading_text] + continue + + # Check for field headings + field_match = field_pattern.match(line) + if field_match and current_object: + field_name = field_match.group(1).strip() + # Look up field in current object (case-insensitive) + idd_field = current_object.fields_by_name.get(field_name.lower()) + if idd_field: + attrs_block = _format_field_attrs(idd_field) + if attrs_block: + result.append("") + result.append(attrs_block) + + return "\n".join(result) + + def postprocess( text: str, title: str | None = None, @@ -403,6 +519,7 @@ def postprocess( rel_depth: int = 0, current_md_path: str = "", figure_numbers: list[int] | None = None, + idd_index: dict[str, IddObject] | None = None, ) -> str: """Apply all postprocessing transformations in the correct order.""" if label_index is None: @@ -424,6 +541,8 @@ def postprocess( text = fix_heading_dashes(text) text = clean_empty_links(text) text = clean_div_wrappers(text) + if idd_index: + text = inject_field_metadata(text, idd_index) text = add_front_matter(text, title, doc_set_title=doc_set_title) return text diff --git a/scripts/models.py b/scripts/models.py index 8a1be8277..f245a96e7 100644 --- a/scripts/models.py +++ b/scripts/models.py @@ -113,3 +113,34 @@ class VersionEntry: version: str title: str aliases: list[str] = field(default_factory=list) + + +@dataclass +class IddField: + """A single field definition from an IDD object.""" + + name: str + field_id: str = "" # "A1", "N2", etc. + field_type: str = "" # "alpha", "real", "integer", "choice", "node", "object-list", "external-list" + units: str = "" + ip_units: str = "" + default: str = "" + minimum: str = "" + minimum_exclusive: bool = False + maximum: str = "" + maximum_exclusive: bool = False + required: bool = False + autosizable: bool = False + autocalculatable: bool = False + keys: list[str] = field(default_factory=list) + notes: str = "" + + +@dataclass +class IddObject: + """An IDF object definition from the IDD file.""" + + name: str + memo: str = "" + fields: list[IddField] = field(default_factory=list) + fields_by_name: dict[str, IddField] = field(default_factory=dict, repr=False) From 0d9e66ad507970165e9595311fa94972cbcb0951 Mon Sep 17 00:00:00 2001 From: Samuel Letellier-Duchesne Date: Fri, 27 Feb 2026 06:00:56 -0500 Subject: [PATCH 2/3] Add dev server configurations to .claude/launch.json Co-Authored-By: Claude Opus 4.6 --- .claude/launch.json | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .claude/launch.json diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 000000000..648e75e3a --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,32 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "dev-v25.2", + "runtimeExecutable": "uv", + "runtimeArgs": [ + "run", + "zensical", + "serve", + "-a", + "localhost:8123" + ], + "cwd": "build/v25.2", + "port": 8123 + }, + { + "name": "serve-dist", + "runtimeExecutable": "uv", + "runtimeArgs": [ + "run", + "python", + "-m", + "http.server", + "8003", + "--directory", + "dist" + ], + "port": 8003 + } + ] +} From 51a04f33940befb692b0d72644a993e925dcdf2e Mon Sep 17 00:00:00 2001 From: Samuel Letellier-Duchesne Date: Fri, 27 Feb 2026 06:42:05 -0500 Subject: [PATCH 3/3] Add sticky headings and optimize preprocessor performance (#17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add object separators with sticky headings and fix O(n²) preprocessor bottleneck Visual: inject
between IDF objects on IO Reference group pages so long pages have clear visual breaks. The heading immediately after each separator becomes position:sticky, staying visible while scrolling through that object's fields. Performance: rewrite _expand_all_bracket_macros() to use regex search instead of character-by-character scanning. The old code created a substring slice on every iteration (`text[i:].startswith(…)`), making it O(n²) for large LaTeX files. The new version jumps directly to the next macro via re.search(), reducing single-version conversion from ~632 s to ~86 s (7.3× faster). Also adds pyinstrument as a dev dependency for future profiling. https://claude.ai/code/session_01BFFEpg2Hxz1hHr8JDZ1P6Y * Add .deb files to .gitignore Ignore downloaded binary packages (e.g. pandoc .deb from pypandoc). https://claude.ai/code/session_01BFFEpg2Hxz1hHr8JDZ1P6Y * Remove pyinstrument from dev dependencies https://claude.ai/code/session_01BFFEpg2Hxz1hHr8JDZ1P6Y --------- Co-authored-by: Claude --- .gitignore | 3 ++ scripts/assets/idf-fields.css | 38 ++++++++++++++++++++++++ scripts/latex_preprocessor.py | 48 ++++++++++++++++++------------- scripts/markdown_postprocessor.py | 15 ++++++++-- 4 files changed, 81 insertions(+), 23 deletions(-) diff --git a/.gitignore b/.gitignore index 09109dfa2..e347f0b1d 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ Thumbs.db # Pre-commit cache .cache + +# Downloaded binaries (e.g. pypandoc) +*.deb diff --git a/scripts/assets/idf-fields.css b/scripts/assets/idf-fields.css index fe7fbfcc8..1dacfd5e9 100644 --- a/scripts/assets/idf-fields.css +++ b/scripts/assets/idf-fields.css @@ -88,3 +88,41 @@ border-radius: 3px; margin-right: 0.2em; } + +/* ── Object separator ── */ +hr.idf-object-separator { + border: none; + border-top: 2px solid var(--md-default-fg-color--lightest); + margin: 2.5em 0 1em; +} + +/* ── Sticky object headings ── + * Any h2/h3 immediately after the separator sticks to the top of the + * viewport while the user scrolls through that object's fields. + */ +hr.idf-object-separator + h2, +hr.idf-object-separator + h3 { + position: sticky; + top: 0; + z-index: 2; + background: var(--md-default-bg-color); + padding-top: 0.4em; + padding-bottom: 0.3em; + margin-top: 0; + /* subtle bottom edge so the heading doesn't blend into content */ + box-shadow: 0 1px 0 var(--md-default-fg-color--lightest); +} + +/* When header is hidden on scroll (e.g. Material "header.autohide"), + * adjust sticky offset to account for the header height. */ +[data-md-header="shadow"] hr.idf-object-separator + h2, +[data-md-header="shadow"] hr.idf-object-separator + h3 { + top: 0; +} + +/* When the Material header is visible, offset below it. + * Material's header is ~3.6rem (default). */ +.md-header--shadow ~ .md-container hr.idf-object-separator + h2, +.md-header--shadow ~ .md-container hr.idf-object-separator + h3 { + top: 0; +} diff --git a/scripts/latex_preprocessor.py b/scripts/latex_preprocessor.py index 49428468a..67e8a0014 100644 --- a/scripts/latex_preprocessor.py +++ b/scripts/latex_preprocessor.py @@ -146,34 +146,42 @@ def _find_brace_content(text: str, start: int) -> tuple[str, int] | None: } +_BRACKET_MACRO_RE = re.compile(r"\\(?:PB|RB|CB)\{") + + def _expand_all_bracket_macros(text: str) -> str: r"""Expand all ``\PB``, ``\RB``, ``\CB`` macros in *text*, inside-out. When an outer macro wraps inner macros (e.g. ``\PB{a \PB{b}}``) the inner content is recursively expanded first, so the final result contains no bracket macros regardless of nesting depth. + + Uses regex to jump to the next macro occurrence instead of scanning + every character, which is critical for large files. """ result: list[str] = [] - i = 0 - while i < len(text): - matched_macro = None - for macro in _BRACKET_MACROS: - if text[i:].startswith(macro + "{"): - matched_macro = macro - break - if matched_macro: - brace_start = i + len(matched_macro) - found = _find_brace_content(text, brace_start) - if found: - content, end = found - # Recursively expand any bracket macros inside the content - content = _expand_all_bracket_macros(content) - left, right = _BRACKET_MACROS[matched_macro] - result.append(f"{left} {content} {right}") - i = end - continue - result.append(text[i]) - i += 1 + pos = 0 + while True: + m = _BRACKET_MACRO_RE.search(text, pos) + if m is None: + result.append(text[pos:]) + break + # Append everything before this macro + result.append(text[pos : m.start()]) + macro = text[m.start() : m.end() - 1] # e.g. "\\PB" + brace_start = m.end() - 1 # position of the '{' + found = _find_brace_content(text, brace_start) + if found: + content, end = found + # Recursively expand any bracket macros inside the content + content = _expand_all_bracket_macros(content) + left, right = _BRACKET_MACROS[macro] + result.append(f"{left} {content} {right}") + pos = end + else: + # Unbalanced brace — emit macro text literally and continue + result.append(text[m.start() : m.end()]) + pos = m.end() return "".join(result) diff --git a/scripts/markdown_postprocessor.py b/scripts/markdown_postprocessor.py index 92fae90ba..e3b99b928 100644 --- a/scripts/markdown_postprocessor.py +++ b/scripts/markdown_postprocessor.py @@ -470,11 +470,13 @@ def inject_field_metadata(text: str, idd_index: dict[str, IddObject]) -> str: Scans the markdown for heading patterns to determine the current IDF object, then looks up field metadata from the IDD index and inserts styled attribute - blocks after each field heading. + blocks after each field heading. Also inserts ``
`` separators between + consecutive IDF objects so long group pages are visually segmented. """ lines = text.split("\n") result: list[str] = [] current_object: IddObject | None = None + seen_first_object = False # Heading patterns # Object headings: # ObjectName or ## ObjectName (for group pages) @@ -483,8 +485,6 @@ def inject_field_metadata(text: str, idd_index: dict[str, IddObject]) -> str: field_pattern = re.compile(r"^####\s+Field:\s*(.+)$") for line in lines: - result.append(line) - # Check for object-level headings (h1, h2, h3) that match an IDD object h_match = h1_pattern.match(line) if h_match: @@ -492,9 +492,18 @@ def inject_field_metadata(text: str, idd_index: dict[str, IddObject]) -> str: # Strip any Pandoc attributes like {#id .class} heading_text = re.sub(r"\s*\{[#.][^}]*\}", "", heading_text).strip() if heading_text in idd_index: + # Insert a visual separator between objects (skip the first) + if seen_first_object: + result.append("") + result.append('
') + result.append("") + seen_first_object = True current_object = idd_index[heading_text] + result.append(line) continue + result.append(line) + # Check for field headings field_match = field_pattern.match(line) if field_match and current_object: