25592fa6c2
ci / test (push) Successful in 6m14s
History-free snapshot of the private development repository at 77a82eb. Code, docs and the public catalogue only: no plant or job knowledge layers, no client, job or producer names (tools/scrub.py clean against the widened term list; a second word-level grep clean). The intro deck is left out because its slide images cannot be checked for job drawings.
522 lines
21 KiB
Python
522 lines
21 KiB
Python
# -*- 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:
|
|
|
|
* `<details>` / `<summary>` 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 '<Section {0} {1}>'.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(<a 256-entry
|
|
table>)` 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
|
|
# <details>, 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 `<details>`.
|
|
|
|
|
|
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 `<div>` 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, '<b>' + _entities(_escape(section.title)) + '</b>']
|
|
if section.problem:
|
|
head.append('<span class="wt-why">— ' +
|
|
_entities(_escape(section.problem)) + '</span>')
|
|
elif section.summary:
|
|
head.append('— ' + _entities(_escape(section.summary)))
|
|
if not shown:
|
|
head.append('<span class="wt-hint">(click to open)</span>')
|
|
return (
|
|
'<div class="wt-fold">'
|
|
'<div id="{0}_h" class="{1}" onclick="wtFold(\'{0}\')">{2}</div>'
|
|
'<div id="{0}" class="wt-body" style="display:{3}">{4}</div>'
|
|
'</div>').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 '<p><i>(nothing)</i></p>'
|
|
out = ['<table><tr>']
|
|
for column in columns:
|
|
out.append('<th>' + _escape(str(column)) + '</th>')
|
|
out.append('</tr>')
|
|
for row in rows:
|
|
out.append('<tr>')
|
|
for cell in row:
|
|
out.append('<td>' + _cell_html(cell) + '</td>')
|
|
out.append('</tr>')
|
|
out.append('</table>')
|
|
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'(?<![A-Za-z0-9])_(.+?)_(?![A-Za-z0-9])')
|
|
|
|
|
|
def md_html(md):
|
|
"""One markdown block -> HTML, for the panel."""
|
|
text_ = md or ''
|
|
if text_.strip() == '---':
|
|
return '<hr/>'
|
|
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'<b>\1</b>', body)
|
|
body = _CODE.sub(r'<code>\1</code>', body)
|
|
body = _ITALIC.sub(r'<i>\1</i>', body)
|
|
body = _entities(body)
|
|
if quoted:
|
|
return ('<div style="border-left:3px solid #9fb0c4;padding-left:8px">'
|
|
'<' + tag + '>' + body + '</' + tag + '></div>')
|
|
return '<' + tag + '>' + body + '</' + tag + '>'
|
|
|
|
|
|
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
|