Skip to content

Report generation performance overhaul: pre-compiled OOXML cache, image renditions, list-numbering fixes#142

Merged
summitt merged 4 commits into
mainfrom
docx-optimization
Jul 16, 2026
Merged

Report generation performance overhaul: pre-compiled OOXML cache, image renditions, list-numbering fixes#142
summitt merged 4 commits into
mainfrom
docx-optimization

Conversation

@summitt

@summitt summitt commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Overhauls DOCX report generation performance for large (400+ vuln) assessments and fixes several list-numbering / namespace correctness bugs that surfaced along the way. Previously report generation spent ~28s in XHTMLImporterImpl.convert per assessment; this change moves that conversion to save time by caching self-contained OOXML snippets.

What changed

Pre-compiled OOXML cache (DocxPrecompiler)

  • Vulnerability desc/rec/details fields are pre-compiled to serialized OOXML at save time (background thread), leaving assessment-level ${...} variables as literal text in <w:t> nodes.
  • Image bytes embedded as data URIs (r:embed) in the cached XML so snippets are self-contained; at report time real image parts are created in the report package and remapped to valid rIds.
  • HTML (Type-3) custom fields are also cached with per-field hashes.
  • Cache version bumps with format changes (v5 → v8) so legacy rows are regenerated.
  • Misnested-list repair (hoistMisnestedLists) runs at compile time.

List-numbering correctness

  • Cached fields now carry the scratch package's numbering definitions (tokenized numId refs) and recreate them under freshly allocated ids at report time — fixes bullets rendering as continued decimal numbers when cached XML referenced ids owned by the report template.
  • A guard refuses numbering references without carried definitions (poisoned-legacy-cache rejection).
  • normalizeNamespaces off-by-one fixed that was reading the w prefix as empty and running a blind replace(" :", " w:") over the whole marshalled XML, corrupting :80w:80 in user text. Element/attribute replacements are now anchored to XML structure.

Image renditions

  • Report-ready rendition stored on Image at upload time (downscaled/re-encoded once via ReportImageScaler); report paths embed it with no decode work. Startup backfill prepares existing images and auto-invalidates on FACTION_REPORT_IMAGE_MAX_WIDTH changes.

Report-time (DocxUtils) optimizations

  • vulnTable/checkTables converts fields in batches with per-vuln placeholder keys (was one importer call per field) and serves cache hits without conversion.
  • wrapHTMLFromCache: variable substitution gated on token presence; deepCopy round trip removed; image parts for known formats created directly instead of docx4j's probing createImagePart.
  • replaceAssessment runs before findings are inserted so its whole-document marshal covers only the template.
  • Extensions: short-circuit updateReport before cloning when text has no ${ placeholder; run extensions at pre-compile time; clear cache on assessment finalize.

Tests

  • DocxListNumberingTest — list numbering verified live, cached, and via vulnTable (bullet numFmt resolution per item, poisoned-legacy-cache rejection, cached image part integrity).
  • DocxAssessmentVarsParityTest — assessment-variable parity across both paths.
  • DocxUtils2ParityTest — DocxUtils2 raw-XML experiment parity.
  • ReportImageScalerTest — image rendition lifecycle.
  • DocxPerfHarness / DocxUtilsBatchIntegrationTest — perf + batch integration.

summitt added 4 commits July 8, 2026 09:52
…ration

Report generation for 400+ vuln assessments was spending ~28s in
XHTMLImporterImpl.convert() calls. This change pre-compiles each
vulnerability's desc/rec/details fields into serialized OOXML at save
time, so report generation can skip the importer entirely for cached
fields.

- Add cachedDescXml/cachedRecXml/cachedDetailsXml + hash fields to
  Vulnerability DAO
- Add DocxPrecompiler that converts HTML to OOXML at save time, leaving
  assessment-level ${...} variables as literal text in <w:t> nodes
- Embed image bytes as data URIs in cached XML (r:embed) so snippets
  are self-contained; at report time, create real image parts in the
  report package and remap to valid rIds
- Add wrapHTMLFromCache fast path in DocxUtils.checkTables and
  setFindings that uses cached XML when hash matches, applies
  assessment vars via string replacement, falls back to live
  conversion on cache miss or when report extensions are active
- Hook precompiler into vuln save paths (assessments API and
  AddVulnerability action) as background threads
- Add startup migration in AppBootstrapListener to pre-compile all
  vulnerabilities in open assessments
- Add DocxUtils2 raw-XML experiment and parity/perf test harness
- Cache version (v5) invalidates prior caches on format changes
…w cache

- Short-circuit Extensions.updateReport() before cloning when text has no
  ${ placeholder
- Remove isExtended() guard in wrapHTMLFromCache so cache is always used
- Skip updateReport() in replacement() for content without ${ placeholders
- Short-circuit per-paragraph in updateDocWithExtensions
- Run extensions at pre-compile time in DocxPrecompiler
- Force-initialize lazy Assessment.answers collection
- Clear cache on assessment finalize
- Bump cache version to v6
Images:
- Store a report-ready rendition on Image at upload time (downscaled or
  re-encoded once); report paths embed it with no decode work. Startup
  backfill prepares existing images; FACTION_REPORT_IMAGE_MAX_WIDTH
  changes invalidate automatically.

Pre-compiled OOXML cache (DocxPrecompiler v2):
- Cached fields are now self-contained: list numbering definitions are
  captured from the scratch package and carried in the payload with
  tokenized numId references, then re-created under freshly allocated
  ids at report time. Fixes bullet lists rendering as continued decimal
  numbers when cached XML referenced ids owned by the report template.
- Cache version bump (v7) invalidates all previous-format rows; a guard
  additionally refuses numbering references without carried definitions.
- Type-3 (HTML) custom fields are cached too (cachedCfXml, one
  marker-delimited entry per field with its own hash).
- Misnested-list repair (hoistMisnestedLists) now runs at compile time.

Report-time (DocxUtils):
- vulnTable/checkTables path converts fields in batches with per-vuln
  placeholder keys (was one importer call per field) and serves cache
  hits without conversion.
- wrapHTMLFromCache: variable substitution scans gated on token
  presence; deepCopy round trip removed; image parts for known formats
  created directly instead of docx4j's probing createImagePart.
- replaceAssessment runs before findings are inserted so its
  whole-document marshal covers only the template; per-field paths now
  also resolve asmtStart/asmtEnd and assessment-level text custom
  fields inside finding content.

Tests: list numbering verified live, cached, and via vulnTable (bullet
numFmt resolution per item, poisoned-legacy-cache rejection, cached
image part integrity); assessment-variable parity across both paths;
image rendition lifecycle.
normalizeNamespaces scanned the xmlns prefix with an off-by-one that
read the usual single-char "w" prefix as an empty string, so the
"w".equals(prefix) early-return never fired and the blind
replace(" :", " w:") ran over the whole marshalled XML — turning every
" :" in user text into " w:" (":80" rendered as "w:80").

The prefix scan is fixed and every replacement is now anchored to XML
structure: element names via '<'/'</' (which cannot appear unescaped in
text), attribute names via their trailing '="'. The regex-fallback
paragraph splitter's prefix rename is anchored the same way. Cache
version bumps to v8 since v7 rows may carry the corruption baked in.
@summitt
summitt merged commit 415d3ee into main Jul 16, 2026
5 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant