Skip to content

add docx export support to workspace-docs blueprint - #439

Draft
maxwellpeterson wants to merge 3 commits into
mainfrom
mpeterson/docx-export
Draft

add docx export support to workspace-docs blueprint#439
maxwellpeterson wants to merge 3 commits into
mainfrom
mpeterson/docx-export

Conversation

@maxwellpeterson

Copy link
Copy Markdown
Member

update built-in docs blueprint to support docx export

@github-actions github-actions Bot added the kernel Changes to the Workshop kernel label Sep 3, 2026
@maxwellpeterson
maxwellpeterson changed the base branch from mpeterson/xlsx-export to main September 3, 2026 18:05
@github-actions github-actions Bot added configurator Changes to gatekeeper configurator UI backend/utils Changes to shared backend utilities workshop/frontend Changes to the Workshop frontend mcp/shared Changes to shared MCP integration code router Changes to the public router delivery Changes to CI or release delivery gatekeeper Changes to a gatekeeper integration labels Sep 3, 2026
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

Preview: pr439-mpeterson-docx-export

https://pr439-mpeterson-docx-export-router.cloudflare-os-previews.workers.dev

Dashboard · deleted when this PR closes

}
},
});
const output = rewriter.transform(new Response(html, {headers: {"content-type": "text/html; charset=utf-8"}}));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Keep accepted data URLs below the parser ceiling

workerd configures the lol-html parser behind HTMLRewriter with a hard 3 MiB memory limit. A start tag containing a 3-8 MiB base64 src has to be buffered before this handler runs, so transform() fails even though DOCX_LIMITS.imageEncodedBytes explicitly accepts 8 MiB and persisted blocks accept roughly 10 MiB. A high-entropy image from the client can hit this range. Enforce a pre-parser limit below the runtime ceiling or avoid HTMLRewriter for these large attributes so inputs within the declared limits can actually export.

stack.at(-1).children.push(node);
if (!VOID_TAGS.has(tag)) {
stack.push(node);
element.onEndTag(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Do not register end handlers on self-closing foreign elements

VOID_TAGS only describes HTML void elements, but valid foreign markup can also be self-closing. For example, <p>before</p><svg/><p>after</p> reaches this branch for svg; lol-html reports that the element cannot have content when onEndTag() is called, failure is set, and the entire export rejects even though SVG is supposed to be ignored. Self-closing descendants such as <svg><path/></svg> fail the same way. Ignore foreign subtrees without installing unsupported end-tag handlers.

}

function decodeHtmlEntities(value) {
return String(value).replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos|nbsp);/gi, (match, entity) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Decode the full HTML named-reference set

lol-html passes text and attribute entities through as-is, but this decoder recognizes only six named references. Standard document HTML such as &copy;, &mdash;, or &hellip; remains unchanged here and is then escaped by xmlText, so Word displays the literal entity spelling rather than the intended character. This affects legacy and programmatically populated documents that have not first round-tripped through the browser. Use a complete HTML named-reference decoder.


function gifDimensions(bytes) {
if (bytes.length < 22 || !["GIF87a", "GIF89a"].includes(ascii(bytes, 0, 6)) ||
bytes.at(-1) !== 0x3b || !bytes.subarray(10, -1).includes(0x2c)) return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Validate GIF frame dimensions before embedding

The logical-screen width and height are not bounds on GIF image descriptors. This check only searches for any 0x2c, so a file can advertise a 1x1 logical screen while a frame descriptor declares 65,535x65,535 pixels; it then bypasses both imageDimension and imagePixels and is embedded for the Office decoder to process. Parse each image descriptor and enforce the per-axis/pixel limits on its frame dimensions (and animation aggregate), rather than trusting only bytes 6-9.

const itemFormat = deriveFormat(format, item);
const itemParagraph = deriveParagraph(paragraphFormat, item, itemFormat);
const paragraph = builder.paragraph({...paragraphOptions("Normal", itemParagraph), list: {numId, level}});
for (const child of item.children) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve nested-list child order

This two-pass conversion emits every non-list child before every nested list. Thus <li>before<ul><li>nested</li></ul>after</li> becomes parent text beforeafter followed by the nested list, changing the document meaning. Process the item children in source order, ending/resuming the parent paragraph around nested lists as needed.

if (node.tag === "h1") return "Heading1";
if (node.tag === "h2") return "Heading2";
if (["h3", "h4", "h5", "h6"].includes(node.tag)) return "Heading3";
if (node.tag === "blockquote") return "Quote";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Distinguish toolbar indentation from quotations

The client toolbar uses Blink execCommand("indent"), which commonly persists normal indentation as <blockquote style="margin: 0 0 0 40px; border: none; padding: 0px;">.... Every such wrapper is mapped to Quote here, while deriveParagraph() also ignores the margin shorthand. Consequently Increase Indent turns ordinary text into a colored, bordered quotation and loses its requested indentation in Word. Detect the editor indentation wrapper and map its margin to paragraph indentation instead.

function decodedImage(source, totals) {
const comma = source.indexOf(",");
if (comma < 0) return null;
const metadata = /^data:(image\/(?:png|jpeg|gif|webp));base64$/i.exec(source.slice(0, comma));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Accept the JPEG spelling supported by the editor

client.js:isSafeImageDataUrl() explicitly accepts data:image/jpg;base64,..., persists it, and renders it, but this regex accepts only image/jpeg. Exporting that supported client content therefore silently replaces the picture with alt text or [Image unavailable]. Accept image/jpg here and normalize it to the JPEG content type/extension.

@ask-bonk

ask-bonk Bot commented Sep 3, 2026

Copy link
Copy Markdown

Submitted 7 actionable inline findings. The targeted 20-test DOCX suite passes.

github run

@ask-bonk

ask-bonk Bot commented Sep 3, 2026

Copy link
Copy Markdown

@maxwellpeterson Bonk workflow failed. Check the logs for details.

View workflow run · To retry, trigger Bonk again.

@github-actions github-actions Bot removed configurator Changes to gatekeeper configurator UI backend/utils Changes to shared backend utilities workshop/frontend Changes to the Workshop frontend mcp/shared Changes to shared MCP integration code router Changes to the public router delivery Changes to CI or release delivery gatekeeper Changes to a gatekeeper integration labels Sep 3, 2026
@ask-bonk

ask-bonk Bot commented Sep 3, 2026

Copy link
Copy Markdown

Findings

  • P1 docx.js:711: Animated WebP ANMF frames bypass frame-count and cumulative-pixel limits, enabling decoder-heavy exports.
  • P2 docx.js:1058: <blockquote><p>A</p><p>B</p></blockquote> becomes one paragraph with duplicate breaks, losing paragraph structure.
  • P2 docx.js:1223: Nested bullets render as literal o because no symbol font or Unicode hollow bullet is specified.
  • P2 docx.js:1224: Ordered lists always start at 1; HTML ol[start] and li[value] are discarded.

github run

@maxwellpeterson

Copy link
Copy Markdown
Member Author

@ask-bonk

@ask-bonk

ask-bonk Bot commented Sep 3, 2026

Copy link
Copy Markdown

PR #439 adds DOCX export to the workspace-docs blueprint, with a preview deployment available.

Bonk review found 11 actionable issues:

  • Image safety/compatibility: parser-size mismatch, GIF frame dimensions, animated WebP limits, and image/jpg rejection.
  • HTML conversion: incomplete entity decoding, self-closing SVG failures, blockquote paragraph collapse, and indentation misread as quotations.
  • Lists: reordered nested content, incorrect nested bullet glyphs, and ignored ordered-list start/value attributes.

The targeted 20-test DOCX suite passes. One later Bonk workflow failed; the thread does not confirm that the findings have been resolved.

github run

@maxwellpeterson

Copy link
Copy Markdown
Member Author

@ask-bonk Please review the current head, 52262c4. The 11 findings from the two earlier reviews were addressed in a5c9124 and 52262c4. Report only remaining actionable findings in the current code; if there are none, say no comments.

@ask-bonk

ask-bonk Bot commented Sep 3, 2026

Copy link
Copy Markdown

Findings

  • P1 docx.js:728: VP8X canvas dimensions override VP8/VP8L dimensions. A 1×1 canvas containing a 16,383×16,383 bitstream bypasses the pixel limit.
  • P1 docx.js:641,744: GIF frame offsets and WebP ANMF offsets are ignored, allowing frame extents far beyond the validated canvas.
  • P2 docx.js:102: Large malformed data URLs are not masked. data:image/png;base64,% plus several MiB still exceeds HTMLRewriter’s parser ceiling instead of producing image fallback text.
  • P2 docx.js:41: Standard block elements such as article and section are treated as inline; adjacent elements collapse into one paragraph without spacing.
  • P2 docx.js:90: Entity decoding still omits semicolonless legacy references and HTML numeric-reference remapping. For example, &copy remains literal and &#128; becomes U+0080 rather than .
  • P2 docx.js:51: The reversed attribute is discarded, so <ol reversed start="3"> exports as 3, 4, 5 instead of 3, 2, 1.

github run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kernel Changes to the Workshop kernel

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant