# -*- coding: utf-8 -*- # Owner, verbatim (2026-08-30), about the reinforcing report: # "最大的,最上面的部分是target跟doner。其余的部分都跟在后面,小一点,并且做成 # 可以折叠的样子。" # "这里面其实有大量的我不在乎的信息。除非有啥问题我感觉我不会读。这种东西都 # 给做成默认折叠。" """Reports that fold: a headline you read, and detail that opens only when it has a problem in it. No Revit here, and nothing report-specific either. THE RULE THIS IMPLEMENTS, AND WHOSE IT IS ------------------------------------------ The owner, 2026-08-30, about the reinforcing report: "The biggest, top-most part is the target and the donor. Everything else follows after, smaller, and made foldable." "There is actually a large amount of information in here that I do not care about. Unless something is wrong I don't think I will read it. Make this kind of thing default-folded." Which is a stronger statement than a layout preference, and it is written down here rather than in one button because it generalises to every report this tool prints: * THE ANSWER IS THE HEADLINE. Biggest, first, never folded. * EVERYTHING ELSE STARTS CLOSED. He will not read it and does not want to see it. * A SECTION THAT CARRIES A PROBLEM OPENS ITSELF. A check that did not run, a caveat that actually bites, a gap somebody has to act on. The reader's contract becomes: **if nothing below the headline is open, nothing needs me.** That contract is the whole value, and it is worth more than any individual section being visible -- which is why `problem` is a SENTENCE naming what is wrong, not a boolean: a section that opens has to say why it opened. * FOLDED IS NOT GONE. Every closed section still prints a one-line header saying what is inside it and how much. Nothing is hidden; things are folded. A caveat that has been removed from the page is a caveat the tool has decided the reader does not need, and this project has already paid for one of those. WHAT THE OUTPUT PANEL ACTUALLY IS, AND WHY THE HTML LOOKS DATED ---------------------------------------------------------------- pyRevit's output window is a `System.Windows.Forms.WebBrowser` (`pyrevit/output/__init__.py`, line 106) -- the IE engine hosted in a WinForms control, NOT a modern browser and not the environment the panellift HTML report renders in. A WinForms WebBrowser runs in IE7 document mode unless the host process has a `FEATURE_BROWSER_EMULATION` registry entry, and pyRevit sets none. So: * `
` / `` DO NOT EXIST here. They are HTML5. This is the trap, because they are the obvious answer and they fail silently -- an unknown element renders as an inline span and the "collapsed" content is simply always visible. * `addEventListener`, `classList`, `querySelector` are all out. * `document.getElementById(id).style.display` and an inline `onclick` attribute are IE5-era DOM and work in every mode. That is what this uses. JavaScript definitely runs in the panel: pyRevit's own charts feature injects Chart.js into it (`coreutils/charts.py`). So the toggle should work. But it has NOT been exercised inside Revit by the agent that wrote it -- there was no bridge available -- so the failure is designed for rather than hoped against: * A PROBLEM SECTION IS NEVER FOLDED IN THE FIRST PLACE. It renders open, as plain HTML, with no dependence on the script running at all. If the toggle is dead, every section that matters is still on the page. * A folded section that will not open loses nothing either, as long as the caller writes the full text out as well -- `text(sections, show_all=True)` is that, and `reinforcereport` hands the button a path to say so in the footer. THE BLOCK SHAPE --------------- A section holds `('md', text)` and `('table', columns, rows)` tuples, the same two the reports already produce. Markdown, not HTML, because the same sections have to render as plain text at a command line -- `tools/reinforce_offline.py` has no panel -- and a report that became HTML-only would have to be written twice and would drift. IronPython 2.7: no f-strings, no pathlib, no dataclasses. """ # pylint: disable=invalid-name import re import textwrap MD = 'md' TABLE = 'table' class Section(object): """One foldable part of a report. `key` an id, unique on the page. Used for the toggle's element id. `title` the one-line header. ALWAYS shown, open or closed. `summary` what is inside, in numbers, shown on the header when closed. A header that says only "details" tells the reader nothing about whether to open it. `blocks` ('md', text) / ('table', columns, rows). `problem` None for routine. A SENTENCE when something in here needs the reader -- it is printed on the header and it forces the section open. `headline` True for the part that is the answer: printed big, first, and never given a fold at all. """ def __init__(self, key, title, summary=None, blocks=None, problem=None, headline=False): self.key = key self.title = title self.summary = summary self.blocks = list(blocks or []) self.problem = problem or None self.headline = bool(headline) @property def is_open(self): """Open when it is the answer, or when it has a problem in it.""" return self.headline or bool(self.problem) def add(self, block): self.blocks.append(block) return self def md(self, text): return self.add((MD, text)) def table(self, columns, rows): return self.add((TABLE, columns, rows)) def __repr__(self): return '
'.format( self.key, 'OPEN' if self.is_open else 'folded') def sections_with_problems(sections): """The ones that opened themselves, in order. What the reader must see.""" return [s for s in sections if s.problem] def problem_count(sections): return len(sections_with_problems(sections)) # ------------------------------------------------------------------ as blocks def blocks(sections, show_all=False): """Every section flattened to blocks, folding the routine ones. A folded section becomes ONE line -- its title, its summary and nothing else. `show_all` prints everything, which is what a file written for the record wants and what `--all` gives at a command line. """ out = [] for section in sections or []: if section.headline: out.extend(section.blocks) continue if section.is_open or show_all: out.append((MD, _open_header(section))) out.extend(section.blocks) else: out.append((MD, _folded_header(section))) return out def _open_header(section): head = '## ' + _text(section.title) if section.problem: head += ' — ' + _text(section.problem) return head def _folded_header(section): bits = ['**' + _text(section.title) + '**'] if section.summary: bits.append(_text(section.summary)) return '- ' + _text(' — ').join(bits) + ' _(folded)_' # ------------------------------------------------------------------- as text def _text(value): """`value` as the interpreter's own text type -- never through `str()`. THE TEXT REPORT LOST THE OWNER'S OWN WORDS THROUGH THIS PATH, measured 2026-08-31 in the sibling renderer: `last-ticketqc.txt` was written with every CJK character of the job file's mute sentences removed -- the ASCII fragments and the gaps between them survived -- while the same sentences rendered correctly in the panel and in `qc-run.json`. A file promised as the complete record that silently drops half a sentence is worse than one that fails to write. `vibe.compat` is the project's one answer to this class: it returns the engine's text type, decoding bytes as UTF-8, and the caller encodes once on the way to disk. Imported lazily so this module keeps the standard- library-only shape it has at import time. """ from vibe import compat return compat.as_text('' if value is None else value) def text(sections, show_all=False, width=78): """The whole report as plain text. What a terminal and a log file want.""" lines = [] for block in blocks(sections, show_all=show_all): lines.append(_text(as_text(block, width=width))) return _text('\n').join(lines) def as_text(block, width=78): if block[0] == TABLE: return table_text(block[1], block[2]) return md_text(block[1], width=width) def md_text(md, width=78): """Markdown as these reports write it -> plain text. Nothing is reworded. Entities become ASCII (the extension is IronPython 2.7, where a non-ASCII source literal is a byte string, so the reports write `—` rather than the character), emphasis marks are dropped, and headings get a rule under them. NOR IS ANYTHING DROPPED. Every line goes out through `_fill`, and `_flat` coerces with `_text` on the way in -- read both before putting a bare `textwrap` call or a `str()` back here. """ body = (md.replace('—', '--').replace(' ', ' ') .replace('†', '+').replace('&', '&')) if body.startswith('# '): title = _flat(body[2:]) return '\n' + '=' * width + '\n' + title + '\n' + '=' * width if body.startswith('## '): title = _flat(body[3:]) return '\n' + title + '\n' + '-' * min(width, len(title)) if body.strip() == '---': return '-' * width quoted = body.startswith('> ') if quoted: body = body[2:] listed = body.startswith('- ') prefix = ' ! ' if quoted else ' ' # A folded section's header is a list item, and its continuation lines are # indented past the dash so a two-line header cannot read as two headers. tail = ' ' * (len(prefix) + (2 if listed else 0)) # NOTHING LONG IS EVER BROKEN. A file path or a piece mark split across # two lines is not a wrapped sentence, it is an unusable value: the reader # copies these into Revit's Select by ID or into a shell. Better a line # that runs past the margin than one that has to be reassembled. return _fill(_flat(body), width, prefix, tail) def _fill(body, width, initial_indent, subsequent_indent): """`textwrap.fill`, with its whitespace TRANSLATE step taken out of the path -- and the reason is invisible on the interpreter the suite runs on. `textwrap` munges whitespace by calling `text.translate()` on anything it takes for a `str`. On CPython that table is applied to a byte string, where every byte has an entry; the engine this extension ships to is IronPython 2.7, whose `str` holds .NET UTF-16 text, so A CHARACTER ABOVE U+00FF HAS NO ENTRY IN THE TABLE AT ALL -- and the report came back missing exactly those characters and nothing else. Measured on the sibling renderer, 2026-08-31. Everything reaching here has already been flattened by `_flat`, so there is no whitespace left to munge and `textwrap` is told not to try. Nothing about the wrapping changes. `ticketpage._wrap_text` is the same cure at the other renderer; the two are one measured defect, not two style preferences. NOTHING LONG IS EVER BROKEN, unchanged: a file path or a piece mark split across two lines is not a wrapped sentence, it is an unusable value -- the reader copies these into Revit or into a shell. """ return textwrap.fill(_text(body), width=width, initial_indent=initial_indent, subsequent_indent=subsequent_indent, break_long_words=False, break_on_hyphens=False, expand_tabs=False, replace_whitespace=False) def _flat(body): return (' '.join(_text(body).split()) .replace('**', '').replace('`', '').replace('__', '')) def table_text(columns, rows): """A fixed-width table. Nothing is truncated: every cell is a measurement or a piece mark, and a silently cut mark is a wrong answer that looks like a right one. The column widths are counted in CHARACTERS, so a cell of CJK -- two columns wide in a terminal font and one character long -- leaves its column looking ragged. That is cosmetic and it is the only thing this gets wrong about such a cell: nothing is dropped, cut or reordered. Straightening it needs east-asian-width tables; nobody has asked.""" if not rows: return ' (nothing)' grid = [[_cell(c) for c in columns]] grid += [[_cell(c) for c in row] for row in rows] width = [max(len(row[i]) for row in grid) for i in range(len(columns))] out = [' ' + ' '.join(c.ljust(width[i]) for i, c in enumerate(grid[0])), ' ' + ' '.join('-' * w for w in width)] for row in grid[1:]: out.append(' ' + ' '.join(c.ljust(width[i]) for i, c in enumerate(row))) return '\n'.join(out) def _cell(value): # `_text`, not `str`: a cell can hold a mark, a note, or a sentence # somebody typed. This is the text path's OTHER coercion boundary -- # `md_text` itself has none, its only exposure was the wrap. return (_text(value).replace('—', '--').replace('†', '+') .replace(' ', ' ').replace('&', '&')) # ------------------------------------------------------------------- as HTML # # Everything below is written for an IE-engine WebBrowser control. See the # module docstring: no HTML5 elements, no modern DOM, inline handlers only. STYLE = """ .wt-fold { margin: 10px 0 0 0; } .wt-head { background: #eef1f5; border-left: 4px solid #9fb0c4; padding: 5px 8px; cursor: pointer; font-size: 8pt; color: #445; } .wt-head-open { background: #f9f2f4; border-left: 4px solid #c7254e; color: #c7254e; cursor: pointer; padding: 5px 8px; font-size: 8pt; } .wt-body { padding: 2px 8px 8px 14px; font-size: 8pt; } .wt-why { font-weight: bold; } .wt-hint { color: #889; font-size: 7pt; } """ # The toggle. `document.getElementById` and `style.display` are IE5-era DOM # and work in every document mode a WinForms WebBrowser can be in; nothing # here uses addEventListener, classList or querySelector, and nothing uses #
, which does not exist in this engine at all. SCRIPT = """ function wtFold(id) { var el = document.getElementById(id); var hd = document.getElementById(id + '_h'); if (!el) { return; } if (el.style.display == 'none') { el.style.display = 'block'; if (hd) { hd.innerHTML = hd.innerHTML.replace('[+]', '[-]'); } } else { el.style.display = 'none'; if (hd) { hd.innerHTML = hd.innerHTML.replace('[-]', '[+]'); } } } function wtClip(id) { var el = document.getElementById(id); var hd = document.getElementById(id + '_h'); if (!el) { return; } if (el.style.maxHeight == 'none') { el.style.maxHeight = el.getAttribute('wtcap'); if (hd) { hd.innerHTML = hd.innerHTML.replace('[-]', '[+]'); } } else { el.style.maxHeight = 'none'; if (hd) { hd.innerHTML = hd.innerHTML.replace('[+]', '[-]'); } } } """ # `wtClip` IS `wtFold`'S SIBLING, NOT A SECOND MECHANISM. # # Same conventions, deliberately: an inline `onclick`, an element id, a # header at `id + '_h'`, and the same `[+]`/`[-]` marker swap. It exists # because the two folds hide differently and one function cannot do both: # # wtFold toggles `display`, for a block that is ABSENT when closed. Used # by every section of a folding report. # wtClip toggles `max-height` against the element's own `wtcap` # attribute, for a block that is WHOLLY PRESENT and merely too # tall -- the checklist's note cells (`ticketpage.note_cell_html`). # Its text stays in the document when collapsed, which is exactly # why it is not `display: none`: a reader can still select and # copy the part he cannot see, and a browser's own find still # reaches it. # # Both are IE5-era DOM. `style.maxHeight` and `getAttribute` are as old as # `style.display`, and nothing here uses addEventListener, classList, # querySelector or `
`. def html(sections, md_to_html=None, headline=False): """The folding half of the report as one HTML string. `headline` sections are skipped by default: the button prints those through pyRevit's own `print_md` / `print_table`, because that is what can linkify an element id and what the reader is used to reading. Pass `headline=True` to render everything. `md_to_html` converts one markdown block. The default handles exactly the vocabulary these reports use; a caller with a real markdown library (the button has `pyrevit.coreutils.markdown`) can pass that instead. """ md_to_html = md_to_html or md_html out = [] for section in sections or []: if section.headline and not headline: continue out.append(_section_html(section, md_to_html)) # ONE LINE, NO NEWLINE ANYWHERE IN IT. pyRevit's `print_html` is a # `print(..., end='')` into the panel's stream, and every newline in that # stream starts a new output entry -- which would cut a `
` in half # and leave a fold's body outside its own container. So joined with # nothing, here and inside each section. return ''.join(out) def _section_html(section, md_to_html): body = [] for block in section.blocks: if block[0] == TABLE: body.append(table_html(block[1], block[2])) else: body.append(md_to_html(block[1])) shown = section.is_open mark = '[-]' if shown else '[+]' # Escaped, then the report's own entities put back -- exactly as # `_cell_html` and `md_html` do. A header may carry `—` like any # other report string; without the restore it would reach the panel as # the literal source text. head = [mark, '' + _entities(_escape(section.title)) + ''] if section.problem: head.append('— ' + _entities(_escape(section.problem)) + '') elif section.summary: head.append('— ' + _entities(_escape(section.summary))) if not shown: head.append('(click to open)') return ( '
' '
{2}
' '
{4}
' '
').format( _id(section.key), 'wt-head-open' if section.problem else 'wt-head', ' '.join(head), 'block' if shown else 'none', ''.join(body)) def _id(key): return 'wt_' + re.sub(r'[^0-9A-Za-z_]', '_', str(key)) def table_html(columns, rows): if not rows: return '

(nothing)

' out = [''] for column in columns: out.append('') out.append('') for row in rows: out.append('') for cell in row: out.append('') out.append('') out.append('
' + _escape(str(column)) + '
' + _cell_html(cell) + '
') return ''.join(out) def _cell_html(value): # Cells already carry the report's own entities (`—`, `†`) # and its marks. They are escaped and the entities put back, so a mark # containing an ampersand cannot break the row and a dagger still prints. return _entities(_escape(str(value))) # The report's whole markdown vocabulary, and nothing else: it is a fixed set # written by one module, not arbitrary user text, so a 30-line converter is # honest here where a general one would be a dependency. _BOLD = re.compile(r'\*\*(.+?)\*\*') _CODE = re.compile(r'`(.+?)`') _ITALIC = re.compile(r'(? HTML, for the panel.""" text_ = md or '' if text_.strip() == '---': return '
' tag, body = 'p', text_ if body.startswith('### '): tag, body = 'h4', body[4:] elif body.startswith('## '): tag, body = 'h3', body[3:] elif body.startswith('# '): tag, body = 'h2', body[2:] quoted = body.startswith('> ') if quoted: body = body[2:] body = _escape(body) body = _BOLD.sub(r'\1', body) body = _CODE.sub(r'\1', body) body = _ITALIC.sub(r'\1', body) body = _entities(body) if quoted: return ('
' '<' + tag + '>' + body + '
') return '<' + tag + '>' + body + '' def _escape(body): return (body.replace('&', '&').replace('<', '<') .replace('>', '>')) def _entities(body): """Put back the handful of entities the reports write on purpose. They are escaped along with everything else on the way in -- that is the point of escaping -- and then restored by name here, so the reports keep writing `—` (ASCII source, IronPython 2.7 safe) and the panel keeps printing an em dash. """ for name in ('mdash', 'dagger', 'nbsp', 'ndash', 'hellip'): body = body.replace('&' + name + ';', '&' + name + ';') return body