Add DOCX import and export for the docs editor#114
Conversation
Covers three-phase plan: prerequisite features (inline images, S3 image storage, web font loading), DOCX import with style mapping, and DOCX export with XML generation. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
13-task plan covering inline image support, S3 image storage, font registry, DOCX import/export, and frontend integration. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Moves inline-splitting logic from insertImageInline into a pure helper in block-helpers.ts, matching the pattern of other apply* functions. Calls normalizeInlines to merge adjacent inlines with equivalent styles. Routes image inline insertion through the table-cell update path when the target block lives inside a table.
Image inlines are measured using their intrinsic dimensions (scaled down if wider than content area), never word-broken, and raise line height as needed. Canvas rendering uses a module-level image cache keyed by src URL; async loads trigger a repaint-only re-render via a new setRequestRender callback on DocCanvas. Note: images inside table cells fall through to the table word-wrap path and render as the placeholder character. Table image support is a follow-up.
Mirrors the image-aware measurement path from layoutBlock into layoutCellInlines so images inside table cells render at their intrinsic size (scaled to fit cell width) and raise cell row height as needed. Also clarifies the onerror comment in the doc-canvas image cache.
MinIO for dev environment, configurable S3 endpoint for production. Supports upload, serve, and delete of image resources. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Rejects GET and DELETE requests where :id does not match the UUID.extension pattern used by uploads. Prevents unauthenticated enumeration of other objects that may share the image bucket.
Supports paragraphs, styled text, tables with cell merge, page setup, headers/footers, and nested table flattening. Images are uploaded via a pluggable callback.
- Image inlines now carry their intrinsic dimensions (EMU->px) from the parsed w:drawing extent instead of 0x0 - Multi-group vMerge in the same column resolves each group independently instead of overwriting the earlier tracker - Nested table flattening walks direct child rows/cells instead of recursively collecting descendants
Supports paragraphs, styled text, tables with cell merge, page setup, headers/footers, page breaks, and image embedding.
Wires DocxImporter and DocxExporter into the frontend UI: - Import DOCX menu item in the document list New dropdown parses the uploaded file, uploads embedded images to the backend image service, creates a new document, and hands off the parsed Document via an in-memory pending-imports map for the editor to apply after mount. - Export as DOCX toolbar button in the body-context formatting toolbar downloads the current document. - Images resolve to absolute URLs via VITE_BACKEND_API_URL so the canvas Image element can fetch them cross-origin.
The image controller uses Express.Multer.File type for uploaded files via FileInterceptor, but @types/multer was not installed, causing a TS2694 compilation error in watch mode. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Calling store.setDocument() with imported blocks leaves the editor's cursor pointing at the initial empty-doc block id, which throws "Block not found" on the next render when the toolbar reads the cursor's block type. Add EditorAPI.resetAfterDocumentReplace() that mirrors the cursor reset logic used by undo/redo: refresh cached document, switch to body context, invalidate layout cache, move cursor to the first block, clear selection, and repaint. DocsView calls it immediately after applying a pending DOCX import. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Knip was scanning git worktrees under .claude/worktrees/ and reporting 437 false-positive "unused file" findings against duplicated source files in sibling branches, which blocked every push that touched verify:entropy. Worktrees are ephemeral development workspaces; they share source with the main tree and should never be considered part of the authoritative codebase for dead-code analysis. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
📝 WalkthroughWalkthroughAdds end-to-end DOCX import/export: new importer/exporter, inline image model and rendering, backend S3-compatible ImageModule (NestJS), frontend import/export UI and flows, font-loading registry, tests, and local MinIO dev service. Changes
Sequence Diagram(s)sequenceDiagram
actor User
participant FE as Frontend
participant Importer as DocxImporter
participant Backend as ImageService
participant S3 as MinIO/S3
participant Editor as EditorAPI
User->>FE: Select .docx file
FE->>Importer: import(buffer, imageUploader)
Importer->>Importer: Parse ZIP & document.xml
loop each embedded image
Importer->>Backend: POST /images (multipart)
Backend->>S3: PutObject(key, bytes)
S3-->>Backend: 200 OK
Backend-->>Importer: { id, url }
end
Importer-->>FE: Parsed Document (images resolved)
FE->>Editor: setPendingImport / resetAfterDocumentReplace()
Editor-->>User: Render document with images
sequenceDiagram
actor User
participant FE as Frontend
participant Exporter as DocxExporter
participant Backend as ImageService
participant S3 as MinIO/S3
User->>FE: Click "Export DOCX"
FE->>Exporter: export(doc, imageFetcher)
loop each inline image
Exporter->>Backend: GET /images/:id
Backend->>S3: GetObject(id)
S3-->>Backend: image bytes
Backend-->>Exporter: image Blob
Exporter->>Exporter: Embed into word/media/
end
Exporter-->>FE: .docx Blob
FE->>User: Trigger browser download
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 15
Note
Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.
🟡 Minor comments (8)
docs/design/docs/docs-docx-import-export.md-441-443 (1)
441-443:⚠️ Potential issue | 🟡 MinorUpdate file-structure docs to match the implemented exporter helper file.
The table lists
docx-builder.ts, but this PR’s export helper isdocx-templates.ts. Please align the design doc to avoid drift.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@docs/design/docs/docs-docx-import-export.md` around lines 441 - 443, Update the design doc's file list to reflect the implemented helper: replace the entry "docx-builder.ts" with "docx-templates.ts" in the table that currently lists "docx-exporter.ts / docx-builder.ts / docx-style-map.ts"; also verify and update any surrounding descriptions that reference docx-builder.ts so they accurately describe docx-templates.ts and its role as the export helper used by docx-exporter.ts and docx-style-map.ts.docs/tasks/active/20260410-docx-import-export-todo.md-1-13 (1)
1-13:⚠️ Potential issue | 🟡 MinorAdd the paired lessons file for this active task.
This is a non-trivial task plan, so it should have a matching
docs/tasks/active/20260410-docx-import-export-lessons.mdfile alongside the TODO.Based on learnings: Applies to docs/tasks/active/*-{todo,lessons}.md : For non-trivial tasks, use paired files in
docs/tasks/active/:YYYYMMDD-<slug>-todo.mdandYYYYMMDD-<slug>-lessons.md.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@docs/tasks/active/20260410-docx-import-export-todo.md` around lines 1 - 13, The TODO file 20260410-docx-import-export-todo.md needs a paired lessons file: add a new 20260410-docx-import-export-lessons.md alongside it that mirrors the TODO’s frontmatter/metadata and directory placement, summarizes post-completion learnings and decisions (key takeaways, pitfalls, useful links like docs/design/docs/docs-docx-import-export.md), and includes references to the implemented pieces (e.g., JSZip + DOMParser import approach, XML generation export, S3 image handling) so readers can trace from the TODO to the final lessons; ensure filenames match the YYYYMMDD-<slug>-lessons.md pattern and update any local task index if one exists.packages/docs/src/store/block-helpers.ts-231-231 (1)
231-231:⚠️ Potential issue | 🟡 MinorAvoid style object aliasing when inserting inline nodes.
{ ...inline }is a shallow copy;inline.style(and nestedstyle.image) remains shared with the caller object.Proposed fix
- newBlock.inlines = normalizeInlines([...before, { ...inline }, ...after]); + const inserted: Inline = { + text: inline.text, + style: { + ...inline.style, + ...(inline.style.image ? { image: { ...inline.style.image } } : {}), + }, + }; + newBlock.inlines = normalizeInlines([...before, inserted, ...after]);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/docs/src/store/block-helpers.ts` at line 231, The inline insertion currently does a shallow copy ({ ...inline }) causing inline.style and nested objects like inline.style.image to remain aliased; update the insertion to deep-clone the inline before passing to normalizeInlines (i.e., replace { ...inline } with a proper deep copy of the inline), implement or call a clone helper (e.g., cloneInline or structuredClone) that copies inline.style and nested style.image so the newBlock.inlines receives an independent object, and use that clonedInline in the normalizeInlines([...before, clonedInline, ...after]) call.packages/docs/test/export/docx-exporter.test.ts-21-105 (1)
21-105:⚠️ Potential issue | 🟡 MinorCover media export, not just text/table round-trips.
The highest-risk exporter path here is packaging
word/media/*and wiring image relationships through the fetcher, but this suite never exports an image-bearing document. Please add one round-trip test that asserts the ZIP contains the media part and rel entry, then verifies re-import preserves the inline image metadata.As per coding guidelines: "Write tests for critical business logic and edge cases".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/docs/test/export/docx-exporter.test.ts` around lines 21 - 105, Add a new test in docx-exporter.test.ts that creates a Document with a paragraph containing an image inline (use generateBlockId(), DEFAULT_BLOCK_STYLE and the Document shape), call DocxExporter.export(doc) and assert the produced ZIP (JSZip.loadAsync on blob.arrayBuffer()) contains a media file under word/media/ and a relationship entry in word/_rels/document.xml.rels, then re-import via DocxImporter.import(buffer) and assert the reimported block's inline preserves the image metadata (e.g., type or src/ref fields on the inline object) to verify round-trip image export/import through DocxExporter.export and DocxImporter.import.packages/docs/test/import/docx-importer.test.ts-48-269 (1)
48-269:⚠️ Potential issue | 🟡 MinorAdd a header/footer import fixture.
The PR adds header/footer support, but this suite never exercises
header*.xml/footer*.xmlrelationships or asserts that imported blocks land indoc.header/doc.footer. A broken rel lookup or section hookup would still pass the current importer tests.Based on learnings: "Write tests for critical business logic and edge cases".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/docs/test/import/docx-importer.test.ts` around lines 48 - 269, Add a test that supplies header/footer parts and relationships to createMinimalDocx and calls DocxImporter.import, ensuring the importer resolves header*.xml/footer*.xml via the package relationships and attaches parsed blocks to doc.header and doc.footer; specifically, create minimal header/footer XML (e.g. a paragraph run), include Relationships XML with Relationship entries pointing to word/header1.xml and word/footer1.xml, pass those files via extraFiles to createMinimalDocx, call DocxImporter.import(buffer) and assert that doc.header and doc.footer are defined and contain the expected blocks/text, referencing DocxImporter.import and the doc.header/doc.footer properties to locate where to assert correct behavior.packages/docs/test/import/docx-parser.test.ts-18-40 (1)
18-40:⚠️ Potential issue | 🟡 MinorUnit-test the drawing/image branch of
parseParagraph.This file only covers text runs and empty paragraphs, but
parseParagraphalso owns the inline-image placeholder and pending-image extraction path. If that branch regresses, failures will surface only in higher-level importer tests and be much harder to localize.As per coding guidelines: "Write tests for critical business logic and edge cases".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/docs/test/import/docx-parser.test.ts` around lines 18 - 40, Add a unit test covering the image/drawing branch of parseParagraph: create a paragraph XML that includes an inline image/drawing element (the inline-image placeholder/pending-image extraction path), call parseParagraph(el) and assert the returned result.inlines contains an image-placeholder inline (e.g., type or marker used by parseParagraph for images) and that the parse result exposes the pending image entry (e.g., result.pendingImages or similar) with expected attributes (id/relationship/alt text). This ensures parseParagraph's inline-image and pending-image extraction paths are exercised alongside the existing text-run and empty-paragraph tests.packages/frontend/src/app/docs/docx-actions.ts-85-88 (1)
85-88:⚠️ Potential issue | 🟡 MinorApply the fallback after sanitizing the title.
If
titleis only whitespace or illegal filename characters,safeTitlebecomes empty and the browser download name becomes.docx. Reapply the"document"fallback afterreplace(...).trim().Suggested fix
- const safeTitle = (title || "document").replace(/[\\/:*?"<>|]+/g, "_").trim(); - const filename = safeTitle.toLowerCase().endsWith(".docx") - ? safeTitle - : `${safeTitle}.docx`; + const sanitizedTitle = (title || "document").replace(/[\\/:*?"<>|]+/g, "_").trim(); + const safeTitle = sanitizedTitle || "document"; + const filename = safeTitle.toLowerCase().endsWith(".docx") + ? safeTitle + : `${safeTitle}.docx`;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/frontend/src/app/docs/docx-actions.ts` around lines 85 - 88, The sanitized filename logic can produce an empty string (e.g., title of only illegal chars or whitespace), resulting in a download name of ".docx"; update the code around safeTitle and filename so you reapply the "document" fallback after calling replace(...).trim(): compute safeTitle = (title || "").replace(/[\\/:*?"<>|]+/g, "_").trim(); if safeTitle === "" set safeTitle = "document"; then build filename from safeTitle and ensure it ends with ".docx" (the symbols to update are safeTitle and filename, and the replace(...).trim() call).packages/docs/src/export/docx-style-map.ts-11-12 (1)
11-12:⚠️ Potential issue | 🟡 MinorEscape
fontFamilybefore writing it into XML attributes.
style.fontFamilyis inserted verbatim intow:rFonts. A name containing&,<, or"will generate malformed OOXML and can break DOCX export for that run.Suggested fix
+function escapeXmlAttr(value: string): string { + return value + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/</g, '<') + .replace(/>/g, '>'); +} + export function buildRunPropertiesXml(style: InlineStyle): string { const parts: string[] = []; if (style.fontFamily) { - parts.push(`<w:rFonts w:ascii="${style.fontFamily}" w:hAnsi="${style.fontFamily}" w:eastAsia="${style.fontFamily}"/>`); + const fontFamily = escapeXmlAttr(style.fontFamily); + parts.push(`<w:rFonts w:ascii="${fontFamily}" w:hAnsi="${fontFamily}" w:eastAsia="${fontFamily}"/>`); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/docs/src/export/docx-style-map.ts` around lines 11 - 12, The code inserts style.fontFamily directly into the w:rFonts XML attribute, which can break DOCX when the font name contains &, <, > or quotes; update the generator in docx-style-map.ts (the block that pushes `<w:rFonts .../>` into parts) to XML-escape the attribute value before interpolation (e.g., use or add a small escapeXml/escapeAttr helper and call it on style.fontFamily) so the resulting w:rFonts attributes are always well-formed.
🧹 Nitpick comments (6)
docs/design/docs/docs-docx-import-export.md (1)
244-245: Minor readability polish: vary sentence starts in the parsing steps.The consecutive “Parse …” openings are repetitive; a small rewording would improve flow.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@docs/design/docs/docs-docx-import-export.md` around lines 244 - 245, The two consecutive lines that start with "Parse ..." (steps referencing "word/numbering.xml" and "word/_rels/document.xml.rels") are repetitive; rephrase one or both to vary sentence openings for readability—e.g., change step 4 to "Handle lists by parsing `word/numbering.xml`" and step 5 to "Map relationship IDs to media files by parsing `word/_rels/document.xml.rels`" (or similar alternatives) so the steps read more smoothly while preserving the same meaning.packages/docs/src/view/fonts.ts (1)
42-44: Consider returning an unsubscribe function fromonFontLoaded.Without a removal path, long-lived editors can retain stale callbacks. Returning a disposer keeps the registry lifecycle-safe.
Refactor sketch
- private listeners: Array<() => void> = []; + private listeners = new Set<() => void>(); - onFontLoaded(cb: () => void): void { - this.listeners.push(cb); + onFontLoaded(cb: () => void): () => void { + this.listeners.add(cb); + return () => this.listeners.delete(cb); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/docs/src/view/fonts.ts` around lines 42 - 44, The onFontLoaded method currently pushes callbacks into this.listeners with no removal path; change onFontLoaded(cb: () => void): () => void to return an unsubscribe function that removes the provided cb from this.listeners (e.g., findIndex/splice or filter) so callers can dispose listeners; ensure the unsubscribe is idempotent (safe if called multiple times) and references the same callback passed to onFontLoaded to remove the exact entry.packages/docs/test/view/fonts.test.ts (1)
4-29: Add tests forensureFontstate transitions and callback behavior.Current coverage validates mapping and default state only; please add edge-case tests for
loaded/errortransitions and listener invocation when fonts load.As per coding guidelines:
**/*.{test,spec}.{js,ts,jsx,tsx}: Write tests for critical business logic and edge cases.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/docs/test/view/fonts.test.ts` around lines 4 - 29, Add unit tests that exercise FontRegistry.ensureFont's lifecycle: call ensureFont for an unknown font on a FontRegistry instance, assert getFontStatus reports 'pending', then simulate a successful load and assert status becomes 'loaded' and any registered success listener/callback was invoked; likewise simulate a load failure and assert status becomes 'error' and error listeners/callbacks were invoked. Use the FontRegistry class, its ensureFont method, and getFontStatus to locate the behavior, and register the same listeners/callbacks your implementation exposes (e.g., onload/onerror or a passed callback) to verify they are called exactly once and that status transitions occur from 'pending' -> 'loaded' or 'pending' -> 'error'. Ensure tests cover both synchronous and asynchronous resolution paths if ensureFont returns a Promise.packages/docs/test/import/units.test.ts (1)
4-31: Add boundary and round-trip assertions for conversion helpers.Please add cases for
0, negative inputs, and round-trip checks (e.g.,px -> twips -> px) to harden these foundational conversions.Based on learnings: "Applies to **/*.{test,spec}.{js,ts,jsx,tsx} : Write tests for critical business logic and edge cases".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/docs/test/import/units.test.ts` around lines 4 - 31, Add edge-case and round-trip assertions to the OOXML conversion tests: for each helper (twipsToPx, emusToPx, halfPointsToPoints, pxToTwips, pxToEmus, pointsToHalfPoints) add assertions for 0 and negative inputs (e.g., -value -> expected negative/zero behavior) and add round-trip checks (e.g., px -> pxToTwips -> twipsToPx returns original px within tolerance, px -> pxToEmus -> emusToPx returns original px, and points -> pointsToHalfPoints -> halfPointsToPoints returns original points). Ensure tolerances match existing toBeCloseTo usage for floating conversions and use exact equality for integer conversions.packages/docs/test/model/types.test.ts (1)
249-261: Expand image-style equality tests to width/height/alt mismatch cases.This suite currently checks only
srcdivergence; add explicit assertions forwidth,height, andaltdifferences to lock down comparison regressions.As per coding guidelines "**/*.{test,spec}.{js,ts,jsx,tsx}: Write tests for critical business logic and edge cases".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/docs/test/model/types.test.ts` around lines 249 - 261, Add new assertions to the existing test that cover image width, height, and alt mismatches: use the InlineStyle fixtures (a, b, c) pattern and call inlineStylesEqual with variants where only width differs, only height differs, and only alt differs to assert false; also include positive assertions where width/height/alt match to ensure true. Locate the test block using the inlineStylesEqual function and InlineStyle type in types.test.ts and add one-liners that mirror the existing src comparison checks for width, height, and alt fields.docker-compose.yaml (1)
20-31: Keep the MinIO admin endpoint local-only by default.
9000:9000/9001:9001plus hard-codedminioadminpublishes the S3 API and console on all interfaces. For a dev-only service, bind these ports to127.0.0.1and/or source the credentials from env so the default admin account is not reachable from the local network.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@docker-compose.yaml` around lines 20 - 31, The MinIO service configuration exposes the API and console on all interfaces and uses hard-coded credentials; update the minio service (service name "minio") to bind ports to localhost (replace "9000:9000" and "9001:9001" with address-restricted mappings to 127.0.0.1) and remove hard-coded MINIO_ROOT_USER/MINIO_ROOT_PASSWORD values so they are sourced from the environment or an external .env/secret; ensure the command "server /data --console-address \":9001\"" continues to work with the localhost binding and keep the existing volume "minio-data:/data".
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/backend/src/image/image.config.ts`:
- Around line 7-8: The config currently uses hardcoded fallbacks for
IMAGE_STORAGE_ACCESS_KEY and IMAGE_STORAGE_SECRET_KEY (accessKey, secretKey)
which exposes non-dev deployments; change image.config.ts so these env vars are
not defaulted to 'minioadmin' — either only allow a default when running in a
local/dev mode (e.g., NODE_ENV === 'development' or an existing isLocalDev
check) or throw an explicit initialization error (fail fast) if they are missing
in non-dev environments; update the code paths that read accessKey/secretKey to
reflect this behavior and add a clear error message referencing
IMAGE_STORAGE_ACCESS_KEY / IMAGE_STORAGE_SECRET_KEY when failing.
In `@packages/backend/src/image/image.service.ts`:
- Around line 69-71: The code currently derives file extension from originalName
(ext = originalName.split('.').pop()) which allows mismatched suffixes; change
key generation in image.service.ts to derive the extension from the validated
mimeType instead (e.g., use a mime-to-extension map based on the incoming
mimeType used earlier), then build key as `${id}.${derivedExt}`; alternatively,
if you want stricter behavior, validate that the originalName suffix matches the
derived extension and reject uploads when they disagree; update references to
randomUUID() and key to use the mime-derived extension so stored keys conform to
VALID_ID_PATTERN used in image.controller.ts.
In `@packages/docs/src/export/docx-exporter.ts`:
- Around line 37-49: The header/footer image handling is missing:
DocxExporter.buildHeaderFooterXml is being called with an empty images array and
only document.xml.rels is written, so images referenced in header/footer are
never packaged or have header/footer-level .rels; fix by passing the actual
header/footer image list from doc.header/doc.footer into
DocxExporter.buildHeaderFooterXml (instead of []), create media parts under
word/media for each header/footer image and add corresponding Relationship
entries with unique rIds into a new header/footer rels file (e.g., write
word/_rels/header1.xml.rels and word/_rels/footer1.xml.rels), update hfRels and
hfContentTypes appropriately, and ensure the rIdCounter is used to generate
unique rIds consistent with those rels (so drawing XML in header/footer points
to the packaged media via the rels you add).
In `@packages/docs/src/import/docx-importer.ts`:
- Around line 302-309: The code currently selects a header/footer by scanning
rels for the first relationship of a given type (using the rels map and the
local variables type/targetFile), which ignores the section's w:sectPr
references; instead, read the active section's w:sectPr and find the matching
w:headerReference/w:footerReference element with the correct type attribute
("default"/"first"/"even"), take its r:id, then map that r:id through
document.xml.rels (the rels map) to get the real rel.target; replace the loop
that picks the first rel by type with logic that resolves r:id from the
section's headerReference/footerReference and then looks up rels.get(rId) (or
rels[rId]) to set targetFile so the actual section-specific header/footer is
imported.
- Around line 273-333: parseHeaderFooter fails to resolve images because
uploadImages only processes document-level relationships; update
parseHeaderFooter to load the header/footer's sibling .rels part from the zip
(the relationships for that specific targetFile), convert that .rels into a
RelEntry map and call uploadImages for that map so image targets get uploaded
and added to imageUrls, and then pass the (merged) imageUrls into
DocxImporter.convertParagraph; this requires adding an ImageUploader parameter
to parseHeaderFooter (or otherwise providing the uploader) and invoking
uploadImages(zip, relsForTarget, uploader, imageUrls) before converting
paragraph nodes so header/footer r:id references resolve.
- Around line 74-96: The resolvedInlines mapping leaves pending placeholders in
place when imageUrls lacks an entry; update the branch that handles
inline.style.image?.src.startsWith('__pending__:') so that if uploaded is
undefined you still consume the corresponding imageRefs entry (keep the refIdx++
behavior) but return the inline with the image removed (e.g., return the same
inline text and style but without the image property) instead of leaving a
"__pending__:*" src; adjust the resolvedInlines mapping around the inlines loop
(variables: inlines, imageUrls, imageRefs, resolvedInlines, emusToPx) to
implement this skip.
In `@packages/docs/src/import/docx-parser.ts`:
- Around line 99-110: The current run reconstruction collects all <w:t> text
then appends a single tab/newline, which loses original child-node order; update
the run processing (where variable r is used and
getElementsByTagNameNS/textEls/tabs/brs are referenced) to iterate r.childNodes
in document order and for each child emit its content: if it's a <w:t> node
append its textContent, if it's a <w:tab> append '\t', if it's a <w:br> append
'\n' (handle other node types by skipping or preserving as before), ensuring you
use the W namespace constant when checking nodeLocalName or namespaceURI so tabs
and breaks are emitted in the exact sequence they appear.
- Around line 63-67: The guard that checks runs' parent (r.parentElement !== pEl
&& r.parentElement?.localName !== 'hyperlink') is empty so nested <w:r> elements
still get processed; update the loop that iterates getElementsByTagNameNS(W,
'r') to skip these nested runs by adding an early continue (or return for a
map/filter) when that condition is true so any run whose parent is not the
paragraph (pEl) and not a hyperlink is ignored; locate the loop handling r and
pEl in docx-parser.ts and add the skip logic immediately after the guard.
In `@packages/docs/src/import/docx-style-map.ts`:
- Around line 49-52: The code maps any w:highlight value (via getW(rPr,
'highlight') and getWAttr(highlight, 'val')) through mapHighlightColor and sets
style.backgroundColor, which incorrectly turns w:highlight="none" into a
fallback color; change the logic in the highlight handling (around getW,
getWAttr, mapHighlightColor and style.backgroundColor) to treat the literal
value 'none' as "no highlight" and skip setting style.backgroundColor (i.e.,
only call mapHighlightColor and assign when val is present and not equal to
'none').
- Around line 25-29: The current logic in docx-style-map uses getW(rPr, 'u') and
then only sets style.underline when getWAttr(u, 'val') exists and is not "none",
which ignores a bare <w:u/> element that should enable underline; update the
check in the block that reads const u = getW(rPr, 'u') so that it treats missing
val as enabled (i.e., if val is undefined or val !== 'none' then set
style.underline = true), using the existing getW and getWAttr helpers and the
style.underline field.
In `@packages/docs/src/import/units.ts`:
- Around line 12-35: The functions pxToTwips, pxToEmus and pointsToHalfPoints
can return fractional values; change each to return an integer by rounding the
computed result (e.g. Math.round) before returning so OOXML attributes serialize
as whole numbers—update pxToTwips(px), pxToEmus(px) and pointsToHalfPoints(pts)
to round their results while leaving emusToPx, pxToEmus and halfPointsToPoints
semantics otherwise unchanged.
In `@packages/docs/src/view/doc-canvas.ts`:
- Around line 25-41: The cached-image branch in getOrLoadImage currently returns
null for a loading image without registering the new onLoad callback, causing
only the first waiter to repaint; update getOrLoadImage so that when
imageCache.get(src) returns a cached Image that is not yet complete
(cached.complete is false), you attach the provided onLoad callback to that
Image (e.g., cached.addEventListener('load', onLoad)) before returning null;
keep the existing behavior for complete/broken images and continue to use
imageCache and the img.onerror handling as-is.
In `@packages/docs/src/view/fonts.ts`:
- Around line 63-67: The try block around document.fonts.load currently includes
this.listeners.forEach so any listener exception falls into the outer catch and
flips this.status.set(key, 'error'); fix by isolating listener failures: after
awaiting document.fonts.load and setting this.status.set(key, 'loaded') (or
before leaving the try), invoke each callback inside its own try/catch (or move
listeners invocation outside the outer try) so thrown errors are caught/logged
locally and do not cause the outer catch to mark the font as 'error'; update the
code paths referenced (document.fonts.load, this.status.set,
this.listeners.forEach) accordingly.
In `@packages/frontend/src/app/docs/docs-view.tsx`:
- Line 17: The current call to takePendingImport() removes the pending import
before applying it, so if store.setDocument(...) or
resetAfterDocumentReplace(...) throws the import is lost; modify the
apply/import flow to catch any errors and re-queue the import on failure (e.g.,
push the object back into the pending-imports source) so users can retry;
specifically update the code paths that call takePendingImport() (the
import/apply block around takePendingImport and the similar block at the area
corresponding to lines 215-223) to wrap the application in try/catch and re-add
the taken pending import inside the catch before rethrowing or handling the
error.
In `@packages/frontend/src/app/docs/yorkie-doc-store.ts`:
- Around line 89-99: The image deserialization currently uses
Number(attrs['image.width']) and Number(attrs['image.height']) which can produce
NaN and persist invalid values; in the block that constructs ImageData (the code
that checks 'image.src' in attrs and assigns style.image), validate the numeric
conversions for width and height using Number(...) and then guard with
Number.isFinite(...) (and optionally check >= 0 or > 0 per UX requirements),
only assign image.width and image.height when the parsed values are finite
(otherwise omit them or set a safe default), so ImageData never gets NaN for
width/height; update the logic around attrs['image.width'],
attrs['image.height'], ImageData and style.image accordingly.
---
Minor comments:
In `@docs/design/docs/docs-docx-import-export.md`:
- Around line 441-443: Update the design doc's file list to reflect the
implemented helper: replace the entry "docx-builder.ts" with "docx-templates.ts"
in the table that currently lists "docx-exporter.ts / docx-builder.ts /
docx-style-map.ts"; also verify and update any surrounding descriptions that
reference docx-builder.ts so they accurately describe docx-templates.ts and its
role as the export helper used by docx-exporter.ts and docx-style-map.ts.
In `@docs/tasks/active/20260410-docx-import-export-todo.md`:
- Around line 1-13: The TODO file 20260410-docx-import-export-todo.md needs a
paired lessons file: add a new 20260410-docx-import-export-lessons.md alongside
it that mirrors the TODO’s frontmatter/metadata and directory placement,
summarizes post-completion learnings and decisions (key takeaways, pitfalls,
useful links like docs/design/docs/docs-docx-import-export.md), and includes
references to the implemented pieces (e.g., JSZip + DOMParser import approach,
XML generation export, S3 image handling) so readers can trace from the TODO to
the final lessons; ensure filenames match the YYYYMMDD-<slug>-lessons.md pattern
and update any local task index if one exists.
In `@packages/docs/src/export/docx-style-map.ts`:
- Around line 11-12: The code inserts style.fontFamily directly into the
w:rFonts XML attribute, which can break DOCX when the font name contains &, <, >
or quotes; update the generator in docx-style-map.ts (the block that pushes
`<w:rFonts .../>` into parts) to XML-escape the attribute value before
interpolation (e.g., use or add a small escapeXml/escapeAttr helper and call it
on style.fontFamily) so the resulting w:rFonts attributes are always
well-formed.
In `@packages/docs/src/store/block-helpers.ts`:
- Line 231: The inline insertion currently does a shallow copy ({ ...inline })
causing inline.style and nested objects like inline.style.image to remain
aliased; update the insertion to deep-clone the inline before passing to
normalizeInlines (i.e., replace { ...inline } with a proper deep copy of the
inline), implement or call a clone helper (e.g., cloneInline or structuredClone)
that copies inline.style and nested style.image so the newBlock.inlines receives
an independent object, and use that clonedInline in the
normalizeInlines([...before, clonedInline, ...after]) call.
In `@packages/docs/test/export/docx-exporter.test.ts`:
- Around line 21-105: Add a new test in docx-exporter.test.ts that creates a
Document with a paragraph containing an image inline (use generateBlockId(),
DEFAULT_BLOCK_STYLE and the Document shape), call DocxExporter.export(doc) and
assert the produced ZIP (JSZip.loadAsync on blob.arrayBuffer()) contains a media
file under word/media/ and a relationship entry in word/_rels/document.xml.rels,
then re-import via DocxImporter.import(buffer) and assert the reimported block's
inline preserves the image metadata (e.g., type or src/ref fields on the inline
object) to verify round-trip image export/import through DocxExporter.export and
DocxImporter.import.
In `@packages/docs/test/import/docx-importer.test.ts`:
- Around line 48-269: Add a test that supplies header/footer parts and
relationships to createMinimalDocx and calls DocxImporter.import, ensuring the
importer resolves header*.xml/footer*.xml via the package relationships and
attaches parsed blocks to doc.header and doc.footer; specifically, create
minimal header/footer XML (e.g. a paragraph run), include Relationships XML with
Relationship entries pointing to word/header1.xml and word/footer1.xml, pass
those files via extraFiles to createMinimalDocx, call
DocxImporter.import(buffer) and assert that doc.header and doc.footer are
defined and contain the expected blocks/text, referencing DocxImporter.import
and the doc.header/doc.footer properties to locate where to assert correct
behavior.
In `@packages/docs/test/import/docx-parser.test.ts`:
- Around line 18-40: Add a unit test covering the image/drawing branch of
parseParagraph: create a paragraph XML that includes an inline image/drawing
element (the inline-image placeholder/pending-image extraction path), call
parseParagraph(el) and assert the returned result.inlines contains an
image-placeholder inline (e.g., type or marker used by parseParagraph for
images) and that the parse result exposes the pending image entry (e.g.,
result.pendingImages or similar) with expected attributes (id/relationship/alt
text). This ensures parseParagraph's inline-image and pending-image extraction
paths are exercised alongside the existing text-run and empty-paragraph tests.
In `@packages/frontend/src/app/docs/docx-actions.ts`:
- Around line 85-88: The sanitized filename logic can produce an empty string
(e.g., title of only illegal chars or whitespace), resulting in a download name
of ".docx"; update the code around safeTitle and filename so you reapply the
"document" fallback after calling replace(...).trim(): compute safeTitle =
(title || "").replace(/[\\/:*?"<>|]+/g, "_").trim(); if safeTitle === "" set
safeTitle = "document"; then build filename from safeTitle and ensure it ends
with ".docx" (the symbols to update are safeTitle and filename, and the
replace(...).trim() call).
---
Nitpick comments:
In `@docker-compose.yaml`:
- Around line 20-31: The MinIO service configuration exposes the API and console
on all interfaces and uses hard-coded credentials; update the minio service
(service name "minio") to bind ports to localhost (replace "9000:9000" and
"9001:9001" with address-restricted mappings to 127.0.0.1) and remove hard-coded
MINIO_ROOT_USER/MINIO_ROOT_PASSWORD values so they are sourced from the
environment or an external .env/secret; ensure the command "server /data
--console-address \":9001\"" continues to work with the localhost binding and
keep the existing volume "minio-data:/data".
In `@docs/design/docs/docs-docx-import-export.md`:
- Around line 244-245: The two consecutive lines that start with "Parse ..."
(steps referencing "word/numbering.xml" and "word/_rels/document.xml.rels") are
repetitive; rephrase one or both to vary sentence openings for readability—e.g.,
change step 4 to "Handle lists by parsing `word/numbering.xml`" and step 5 to
"Map relationship IDs to media files by parsing `word/_rels/document.xml.rels`"
(or similar alternatives) so the steps read more smoothly while preserving the
same meaning.
In `@packages/docs/src/view/fonts.ts`:
- Around line 42-44: The onFontLoaded method currently pushes callbacks into
this.listeners with no removal path; change onFontLoaded(cb: () => void): () =>
void to return an unsubscribe function that removes the provided cb from
this.listeners (e.g., findIndex/splice or filter) so callers can dispose
listeners; ensure the unsubscribe is idempotent (safe if called multiple times)
and references the same callback passed to onFontLoaded to remove the exact
entry.
In `@packages/docs/test/import/units.test.ts`:
- Around line 4-31: Add edge-case and round-trip assertions to the OOXML
conversion tests: for each helper (twipsToPx, emusToPx, halfPointsToPoints,
pxToTwips, pxToEmus, pointsToHalfPoints) add assertions for 0 and negative
inputs (e.g., -value -> expected negative/zero behavior) and add round-trip
checks (e.g., px -> pxToTwips -> twipsToPx returns original px within tolerance,
px -> pxToEmus -> emusToPx returns original px, and points -> pointsToHalfPoints
-> halfPointsToPoints returns original points). Ensure tolerances match existing
toBeCloseTo usage for floating conversions and use exact equality for integer
conversions.
In `@packages/docs/test/model/types.test.ts`:
- Around line 249-261: Add new assertions to the existing test that cover image
width, height, and alt mismatches: use the InlineStyle fixtures (a, b, c)
pattern and call inlineStylesEqual with variants where only width differs, only
height differs, and only alt differs to assert false; also include positive
assertions where width/height/alt match to ensure true. Locate the test block
using the inlineStylesEqual function and InlineStyle type in types.test.ts and
add one-liners that mirror the existing src comparison checks for width, height,
and alt fields.
In `@packages/docs/test/view/fonts.test.ts`:
- Around line 4-29: Add unit tests that exercise FontRegistry.ensureFont's
lifecycle: call ensureFont for an unknown font on a FontRegistry instance,
assert getFontStatus reports 'pending', then simulate a successful load and
assert status becomes 'loaded' and any registered success listener/callback was
invoked; likewise simulate a load failure and assert status becomes 'error' and
error listeners/callbacks were invoked. Use the FontRegistry class, its
ensureFont method, and getFontStatus to locate the behavior, and register the
same listeners/callbacks your implementation exposes (e.g., onload/onerror or a
passed callback) to verify they are called exactly once and that status
transitions occur from 'pending' -> 'loaded' or 'pending' -> 'error'. Ensure
tests cover both synchronous and asynchronous resolution paths if ensureFont
returns a Promise.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: ed1567d4-fc0a-4ffd-aad9-294e0c35e4f4
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (43)
docker-compose.yamldocs/design/docs/docs-docx-import-export.mddocs/tasks/active/20260410-docx-import-export-todo.mdknip.jsonpackages/backend/package.jsonpackages/backend/src/app.module.tspackages/backend/src/image/image.config.tspackages/backend/src/image/image.controller.tspackages/backend/src/image/image.module.tspackages/backend/src/image/image.service.tspackages/docs/package.jsonpackages/docs/src/export/docx-exporter.tspackages/docs/src/export/docx-style-map.tspackages/docs/src/export/docx-templates.tspackages/docs/src/import/docx-importer.tspackages/docs/src/import/docx-parser.tspackages/docs/src/import/docx-style-map.tspackages/docs/src/import/units.tspackages/docs/src/index.tspackages/docs/src/model/document.tspackages/docs/src/model/types.tspackages/docs/src/store/block-helpers.tspackages/docs/src/view/doc-canvas.tspackages/docs/src/view/editor.tspackages/docs/src/view/fonts.tspackages/docs/src/view/layout.tspackages/docs/src/view/table-layout.tspackages/docs/test/export/docx-exporter.test.tspackages/docs/test/export/docx-style-map.test.tspackages/docs/test/import/docx-importer.test.tspackages/docs/test/import/docx-parser.test.tspackages/docs/test/import/docx-style-map.test.tspackages/docs/test/import/units.test.tspackages/docs/test/model/document.test.tspackages/docs/test/model/types.test.tspackages/docs/test/view/fonts.test.tspackages/frontend/src/app/docs/docs-detail.tsxpackages/frontend/src/app/docs/docs-formatting-toolbar.tsxpackages/frontend/src/app/docs/docs-view.tsxpackages/frontend/src/app/docs/docx-actions.tspackages/frontend/src/app/docs/pending-imports.tspackages/frontend/src/app/docs/yorkie-doc-store.tspackages/frontend/src/app/documents/document-list.tsx
Verification: verify:selfResult: ✅ PASS in 117.0s
Verification: verify:integrationResult: ✅ PASS |
Welcome to Codecov 🎉Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests. ℹ️ You can also turn on project coverage checks and project coverage reporting on Pull Request comment Thanks for integrating Codecov - We've got you covered ☂️ |
The MinIO defaults previously applied unconditionally, leaving misconfigured production deployments authenticated with predictable credentials instead of failing fast. Gate those fallbacks behind a non-production check so prod environments surface missing config errors at bucket access time. Also derive the stored extension from the validated MIME type. A valid PNG uploaded with a filename like foo.bmp would otherwise be saved as <uuid>.bmp and later fail the VALID_ID_PATTERN check on retrieval, bricking a legitimate upload. Addresses CodeRabbit PR #114 review.
- parseParagraph: skip runs nested inside structures we don't handle (e.g. w:sdt) instead of letting them leak into the outer paragraph. - parseParagraph: walk run children in document order so that a run shaped like "A<w:tab/>B<w:br/>C" produces "A\tB\nC" instead of the previous "ABC\t\n" collapse. - mapRunProperties: treat bare <w:u/> without a w:val attribute as underline enabled, matching OOXML semantics. - mapRunProperties: short-circuit highlight="none" so the unknown-name fallback in mapHighlightColor no longer paints cleared highlights yellow. Addresses CodeRabbit PR #114 review. Adds regression tests for each case.
pxToTwips, pxToEmus, and pointsToHalfPoints previously returned fractional values, which are not valid for OOXML integer attributes like w:pgSz/@w:w or wp:extent/@cx. Round at the source and drop the now-redundant Math.round calls at the exporter call sites. Addresses CodeRabbit PR #114 review, with a regression test covering non-integer inputs.
- Drop pending image placeholders when the uploader is missing (or the referenced rId is not in scope) instead of leaving __pending__:* sources on the returned document. - Walk each header/footer part's own .rels file, giving it a part-scoped imageUrls map, so images referenced only from header1.xml.rels resolve correctly and don't collide with document.xml.rels rIds. - Select the active header/footer part from <w:sectPr>'s headerReference / footerReference (preferring w:type="default") rather than the first matching relationship, matching real Word documents. Addresses CodeRabbit PR #114 review (issues 4, 5, 6). Adds regression tests for each scenario.
buildHeaderFooterXml was previously called with an empty image-entries list, so any <w:drawing> emitted inside a header or footer pointed at a rId that nothing in the package resolved. Collect header and footer images into their own entry lists (with part-scoped rId counters and a shared media-filename sequence to avoid word/media/ collisions), wire those entries through to buildHeaderFooterXml, and emit word/_rels/header1.xml.rels and word/_rels/footer1.xml.rels so the drawings resolve when the file is opened in Word. Addresses CodeRabbit PR #114 review (issue 3) with a regression test covering packaging.
DocCanvas.getOrLoadImage returned null for in-flight loads without registering the caller's onLoad, so a second DocCanvas (or a re-render) triggered before the image completed would lose its re-paint callback and never repaint with the image. Track pending callbacks per src and fan out on load/error/image. FontRegistry.ensureFont's listener loop ran inside the same try/catch as the font load, so a listener throwing would mark the font as 'error' even though it had just loaded. Settle the status before invoking listeners and wrap each callback so one bad subscriber does not suppress the rest. Addresses CodeRabbit PR #114 review.
docs-view used takePendingImport, which removed the registry entry before the apply step. A throw from store.setDocument left the user with no retry path and the parsed import permanently lost. Switch to peek + explicit clear so a failing apply leaves the entry intact for the next mount. yorkie-doc-store.parseInlineStyle accepted `image.src` attributes with NaN or non-positive dimensions, materialising garbage image data into the in-memory document (and persisting it back on the next write). Validate width/height with Number.isFinite and a positivity check, dropping the image style when either is missing or malformed. Addresses CodeRabbit PR #114 review.
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (3)
packages/docs/test/import/docx-importer.test.ts (2)
383-395: UsetoHaveLengthfor consistency with other array length assertions.Minor consistency nit: Line 388 uses
.lengthproperty while other tests use thetoHaveLengthmatcher.♻️ Proposed fix
- expect(uploaded.length).toBe(1); + expect(uploaded).toHaveLength(1);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/docs/test/import/docx-importer.test.ts` around lines 383 - 395, Replace the array length assertion for the uploaded array with the Jest matcher toHaveLength for consistency: change the assertion that inspects uploaded.length (the expect(uploaded.length).toBe(1) in the test around DocxImporter.import) to use expect(uploaded).toHaveLength(1) so it matches other tests' style and intent.
143-150: Consider extracting the minimal PNG bytes as a shared constant.The 1x1 transparent PNG byte array is duplicated in two tests. Extracting it to a module-level constant would reduce duplication and make it reusable for future image-related tests.
♻️ Proposed refactor
+// Minimal 1x1 transparent PNG — content doesn't matter, only valid structure. +const MINIMAL_PNG_BYTES = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, + 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x62, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, + 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, +]); + describe('DocxImporter', () => {Then replace both inline declarations with
MINIMAL_PNG_BYTES.Also applies to: 359-366
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/docs/test/import/docx-importer.test.ts` around lines 143 - 150, Extract the duplicated 1x1 transparent PNG byte array into a module-level constant named MINIMAL_PNG_BYTES and replace each inline declaration of pngBytes in the tests with this constant; locate the occurrences of pngBytes in docx-importer.test (both around lines shown) and create the constant near the top of the test file (or in a shared test utils module if used across files), then update the tests to reference MINIMAL_PNG_BYTES instead of re-declaring the Uint8Array.packages/docs/src/view/fonts.ts (1)
42-44: Provide an unsubscribe handle and notify from a snapshot.
onFontLoadedcurrently only adds listeners, so stale subscribers can accumulate. Also, iterating the live collection allows re-entrant mutation during notification.Proposed refactor
export class FontRegistry { private status = new Map<string, FontStatus>(); - private listeners: Array<() => void> = []; + private listeners = new Set<() => void>(); @@ - onFontLoaded(cb: () => void): void { - this.listeners.push(cb); + onFontLoaded(cb: () => void): () => void { + this.listeners.add(cb); + return () => { + this.listeners.delete(cb); + }; } @@ - for (const cb of this.listeners) { + for (const cb of Array.from(this.listeners)) { try { cb(); } catch (e) { // eslint-disable-next-line no-console console.error('FontRegistry listener threw:', e); } }Also applies to: 74-81
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/docs/src/view/fonts.ts` around lines 42 - 44, onFontLoaded should return an unsubscribe handle and notifications must iterate a snapshot to avoid re-entrant mutation; change onFontLoaded to add the callback to this.listeners and return a function that removes that callback from this.listeners (so subscribers can unsubscribe), and when dispatching (the notifier function that currently iterates this.listeners) iterate over a shallow copy like Array.from(this.listeners) or [...this.listeners] before invoking callbacks to prevent mutation during notification; apply the same unsubscribe-returning pattern and snapshot iteration to the other listener API at the 74-81 region as well.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/docs/src/export/docx-exporter.ts`:
- Around line 283-287: The code currently derives the image extension from the
URL (src) which is unreliable; instead, after calling fetcher(src) use the
fetched response/blob to determine the media type (e.g., use
response.headers.get('content-type') or blob.type) and map that MIME type to a
correct extension before calling nextMediaName(ext) and zip.file(`word/${path}`,
blob); if the Content-Type is missing or unknown, fallback to 'png'. Update the
logic around fetcher/src, blob, ext, nextMediaName, and zip.file to use the
blob/response-derived MIME-to-extension mapping.
- Around line 151-165: In docx-exporter.ts inside the image handling branch
(where inline.style.image is checked and imageEntries is searched), stop
silently falling through when no ImageEntry is found: if const entry =
imageEntries.find(e => e.src === inline.style.image!.src) returns undefined,
throw a descriptive Error (include the missing image src and mention
imageFetcher/ImageEntry) instead of returning a text run; this makes the failure
fast and surfaced to callers (update any callers/tests expecting a different
behavior if needed).
In `@packages/docs/src/view/fonts.ts`:
- Around line 21-27: The resolveFontFamily function currently interpolates the
raw family string into a CSS font-family value which can break when family
contains quotes or backslashes; update resolveFontFamily to sanitize/escape the
dynamic family (escape backslashes and any single quotes) before composing the
return value so the produced string is safe for CSS; keep using FONT_MAP and
SERIF_FONTS to short-circuit mapped or generic lookups but always apply escaping
to the family variable when constructing `'${family}', ${generic}` to prevent
invalid CSS from custom DOCX font names.
In `@packages/frontend/src/app/docs/docs-view.tsx`:
- Around line 221-226: The pending DOCX import can remain if
store.setDocument(pending) succeeds but editor.resetAfterDocumentReplace()
throws, so move the clearPendingImport call to immediately after a successful
store.setDocument(pending) and before editor.resetAfterDocumentReplace(); i.e.,
call clearPendingImport(documentId) right after store.setDocument(pending) so
the pending entry is removed as soon as the document is applied, then run
editor.resetAfterDocumentReplace(), keeping the try/catch around the operations
to still log failures.
---
Nitpick comments:
In `@packages/docs/src/view/fonts.ts`:
- Around line 42-44: onFontLoaded should return an unsubscribe handle and
notifications must iterate a snapshot to avoid re-entrant mutation; change
onFontLoaded to add the callback to this.listeners and return a function that
removes that callback from this.listeners (so subscribers can unsubscribe), and
when dispatching (the notifier function that currently iterates this.listeners)
iterate over a shallow copy like Array.from(this.listeners) or
[...this.listeners] before invoking callbacks to prevent mutation during
notification; apply the same unsubscribe-returning pattern and snapshot
iteration to the other listener API at the 74-81 region as well.
In `@packages/docs/test/import/docx-importer.test.ts`:
- Around line 383-395: Replace the array length assertion for the uploaded array
with the Jest matcher toHaveLength for consistency: change the assertion that
inspects uploaded.length (the expect(uploaded.length).toBe(1) in the test around
DocxImporter.import) to use expect(uploaded).toHaveLength(1) so it matches other
tests' style and intent.
- Around line 143-150: Extract the duplicated 1x1 transparent PNG byte array
into a module-level constant named MINIMAL_PNG_BYTES and replace each inline
declaration of pngBytes in the tests with this constant; locate the occurrences
of pngBytes in docx-importer.test (both around lines shown) and create the
constant near the top of the test file (or in a shared test utils module if used
across files), then update the tests to reference MINIMAL_PNG_BYTES instead of
re-declaring the Uint8Array.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 8a5f3f49-76d8-4082-9213-371c14d638fa
📒 Files selected for processing (18)
packages/backend/src/image/image.config.tspackages/backend/src/image/image.service.tspackages/docs/src/export/docx-exporter.tspackages/docs/src/export/docx-style-map.tspackages/docs/src/import/docx-importer.tspackages/docs/src/import/docx-parser.tspackages/docs/src/import/docx-style-map.tspackages/docs/src/import/units.tspackages/docs/src/view/doc-canvas.tspackages/docs/src/view/fonts.tspackages/docs/test/export/docx-exporter.test.tspackages/docs/test/import/docx-importer.test.tspackages/docs/test/import/docx-parser.test.tspackages/docs/test/import/docx-style-map.test.tspackages/docs/test/import/units.test.tspackages/frontend/src/app/docs/docs-view.tsxpackages/frontend/src/app/docs/pending-imports.tspackages/frontend/src/app/docs/yorkie-doc-store.ts
✅ Files skipped from review due to trivial changes (2)
- packages/docs/src/export/docx-style-map.ts
- packages/docs/test/import/docx-style-map.test.ts
🚧 Files skipped from review as they are similar to previous changes (10)
- packages/backend/src/image/image.config.ts
- packages/frontend/src/app/docs/yorkie-doc-store.ts
- packages/docs/src/import/units.ts
- packages/backend/src/image/image.service.ts
- packages/docs/test/import/units.test.ts
- packages/docs/src/import/docx-parser.ts
- packages/docs/src/view/doc-canvas.ts
- packages/docs/src/import/docx-style-map.ts
- packages/docs/test/export/docx-exporter.test.ts
- packages/docs/src/import/docx-importer.ts
- Exporter throws when an image inline lacks a matching media entry instead of silently falling through to a text run - Media file extension is now derived from the fetched blob's MIME type rather than the (potentially extensionless) URL - Font family names are CSS-escaped before interpolation so custom DOCX fonts with quotes/backslashes cannot break the generated font-family string - Pending DOCX imports are cleared immediately after setDocument succeeds so a later resetAfterDocumentReplace failure cannot cause re-application on the next mount Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
packages/docs/src/export/docx-exporter.ts (2)
203-207: Hardcoded table width doesn't account for actual page setup.The
totalTwips = 9000assumption works for typical Letter-size documents, but won't adapt if the document uses different page sizes or margins. Tables may not fill the expected content area on non-standard page configurations.This is acceptable for v1 but worth noting for future improvement.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/docs/src/export/docx-exporter.ts` around lines 203 - 207, The hardcoded totalTwips = 9000 used to generate gridCols from tableData.columnWidths will mis-size tables for non-standard pages; replace that constant with a computed content width derived from the document/section page width minus left and right margins (e.g., compute contentTwips = (pageWidth - marginLeft - marginRight) in twips) and use Math.round(w * contentTwips) when building gridCols so the table fills the actual page content area; locate totalTwips, gridCols and tableData.columnWidths in docx-exporter.ts and pull pageWidth/margins from the document/section properties or accept them as parameters if not available.
58-63: Consider parallelizing image fetches for better performance.Images are fetched sequentially via
awaitin a loop. For documents with many images, this could be slow. Parallel fetching withPromise.allwould improve performance, though you'd need to handle the deduplication logic differently.💡 Example approach (optional)
// Collect unique image sources first, then fetch in parallel const uniqueSrcs = new Set<string>(); for (const block of doc.blocks) { DocxExporter.collectImageSrcs(block, uniqueSrcs); } await Promise.all([...uniqueSrcs].map(async (src) => { const blob = await imageFetcher(src); // ... rest of logic }));🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/docs/src/export/docx-exporter.ts` around lines 58 - 63, The image fetching loop in DocxExporter (iterating over doc.blocks and awaiting DocxExporter.collectImages) runs sequentially and should be parallelized: first traverse doc.blocks to collect unique image sources (e.g., add a new helper DocxExporter.collectImageSrcs) into a Set, then kick off parallel fetches via Promise.all calling imageFetcher for each unique src and updating docImageEntries/zip/media naming atomically; ensure deduplication uses docImageEntries (or a new map) to assign and reuse nextDocRId/nextMediaName safely (atomic increments or deterministic naming) so the rest of the export logic that relies on those identifiers remains correct.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/docs/src/export/docx-exporter.ts`:
- Around line 179-187: The returned image XML currently hardcodes wp:docPr
id="1" and pic:cNvPr id="1", which must be unique; update the image-rendering
routine in docx-exporter.ts to accept or use an image counter/uniqueId and
substitute it into both wp:docPr and pic:cNvPr attributes (instead of the
hardcoded "1"), ensure the counter increments per image (so each call producing
the string uses the same unique id for both wp:docPr and pic:cNvPr), and keep
using entry.rId for the relationship reference.
---
Nitpick comments:
In `@packages/docs/src/export/docx-exporter.ts`:
- Around line 203-207: The hardcoded totalTwips = 9000 used to generate gridCols
from tableData.columnWidths will mis-size tables for non-standard pages; replace
that constant with a computed content width derived from the document/section
page width minus left and right margins (e.g., compute contentTwips = (pageWidth
- marginLeft - marginRight) in twips) and use Math.round(w * contentTwips) when
building gridCols so the table fills the actual page content area; locate
totalTwips, gridCols and tableData.columnWidths in docx-exporter.ts and pull
pageWidth/margins from the document/section properties or accept them as
parameters if not available.
- Around line 58-63: The image fetching loop in DocxExporter (iterating over
doc.blocks and awaiting DocxExporter.collectImages) runs sequentially and should
be parallelized: first traverse doc.blocks to collect unique image sources
(e.g., add a new helper DocxExporter.collectImageSrcs) into a Set, then kick off
parallel fetches via Promise.all calling imageFetcher for each unique src and
updating docImageEntries/zip/media naming atomically; ensure deduplication uses
docImageEntries (or a new map) to assign and reuse nextDocRId/nextMediaName
safely (atomic increments or deterministic naming) so the rest of the export
logic that relies on those identifiers remains correct.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: f8099365-690a-45d4-9a36-5cb7a1962db2
📒 Files selected for processing (6)
packages/docs/src/export/docx-exporter.tspackages/docs/src/export/docx-templates.tspackages/docs/src/view/fonts.tspackages/docs/test/export/docx-exporter.test.tspackages/docs/test/view/fonts.test.tspackages/frontend/src/app/docs/docs-view.tsx
✅ Files skipped from review due to trivial changes (2)
- packages/docs/src/view/fonts.ts
- packages/docs/src/export/docx-templates.ts
🚧 Files skipped from review as they are similar to previous changes (3)
- packages/frontend/src/app/docs/docs-view.tsx
- packages/docs/test/export/docx-exporter.test.ts
- packages/docs/test/view/fonts.test.ts
| return `<w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0"> | ||
| <wp:extent cx="${cx}" cy="${cy}"/> | ||
| <wp:docPr id="1" name="Image"/> | ||
| <a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"> | ||
| <pic:pic><pic:nvPicPr><pic:cNvPr id="1" name="Image"/><pic:cNvPicPr/></pic:nvPicPr> | ||
| <pic:blipFill><a:blip r:embed="${entry.rId}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill> | ||
| <pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${cx}" cy="${cy}"/></a:xfrm> | ||
| <a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic> | ||
| </a:graphicData></a:graphic></wp:inline></w:drawing></w:r>`; |
There was a problem hiding this comment.
Hardcoded id="1" for all images may cause issues in some viewers.
The wp:docPr and pic:cNvPr elements both use id="1" for every image. Per OOXML spec, these should be unique within the document. While most Word implementations tolerate duplicates, this can cause:
- Accessibility tool confusion
- Document repair prompts in strict validators
- Issues with certain Word features that rely on unique IDs
💡 Suggested fix: Pass a counter for unique IDs
private static inlineToXml(
inline: Inline,
imageEntries: ImageEntry[],
+ docPrIdCounter: { value: number },
): string {
if (inline.style.image) {
const entry = imageEntries.find((e) => e.src === inline.style.image!.src);
if (!entry) { /* ... */ }
+ const docPrId = docPrIdCounter.value++;
const cx = pxToEmus(inline.style.image.width);
const cy = pxToEmus(inline.style.image.height);
return `<w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0">
<wp:extent cx="${cx}" cy="${cy}"/>
- <wp:docPr id="1" name="Image"/>
+ <wp:docPr id="${docPrId}" name="Image"/>
<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">
- <pic:pic><pic:nvPicPr><pic:cNvPr id="1" name="Image"/><pic:cNvPicPr/></pic:nvPicPr>
+ <pic:pic><pic:nvPicPr><pic:cNvPr id="${docPrId}" name="Image"/><pic:cNvPicPr/></pic:nvPicPr>
...🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/docs/src/export/docx-exporter.ts` around lines 179 - 187, The
returned image XML currently hardcodes wp:docPr id="1" and pic:cNvPr id="1",
which must be unique; update the image-rendering routine in docx-exporter.ts to
accept or use an image counter/uniqueId and substitute it into both wp:docPr and
pic:cNvPr attributes (instead of the hardcoded "1"), ensure the counter
increments per image (so each call producing the string uses the same unique id
for both wp:docPr and pic:cNvPr), and keep using entry.rId for the relationship
reference.
Adds bidirectional Microsoft Word (.docx) import and export to the docs editor, including inline images, styled runs, tables with cell merge, page setup, and headers/footers. Conversion lives at the model layer (packages/docs/src/import, packages/docs/src/export) so both sides are isomorphic and reuse the Document/Block/Inline types. Prerequisite features added to represent real-world .docx content: - Inline image support: ImageData on InlineStyle (\uFFFC placeholder text), Doc.insertImageInline via a shared applyInsertInline helper, Yorkie serialization with NaN guards, image measurement/rendering in layout.ts, doc-canvas.ts, and table-layout.ts with a shared image cache that subscribes every waiting canvas to in-flight loads - Image resource service: NestJS ImageModule backed by S3-compatible storage (MinIO in dev via docker-compose, real S3 via env config), UUID-based keys with MIME-derived extensions, fail-fast fallbacks in production, UUID-format validation on GET/DELETE to prevent arbitrary key access - Font registry: CSS fallback chains for Korean typefaces (맑은 고딕, 바탕, HY헤드라인M) with on-demand loading via the browser Font Loading API, CSS-escaped family names, and listener error isolation DOCX import (packages/docs/src/import): - Unit conversions (twips / EMU / half-points ↔ CSS px, rounded on the reverse direction for valid OOXML integers) - OOXML → Docs style mapping for runs, paragraphs, table cells, and highlights, with correct handling of bare <w:u/> and highlight="none" - XML parser utilities for relationships, paragraphs (preserving document order of <w:t>/<w:tab/>/<w:br/>), and page setup - DocxImporter that walks <w:body> direct children, handles gridSpan and multi-group vMerge independently, flattens nested tables to text via direct-child traversal, resolves header/footer via sectPr references, and uploads images per-part with scoped rels DOCX export (packages/docs/src/export): - Reverse style mapping and OOXML templates - DocxExporter that packages document.xml, header1.xml, footer1.xml, and per-part _rels files via JSZip. Media extensions are derived from fetched blob MIME types, not URL suffixes. Missing image entries throw descriptive errors instead of silently falling through to text runs. Round-trip tested against DocxImporter. Frontend integration: - Import DOCX menu item in the document list New dropdown parses the file, uploads embedded images, creates a new document, and hands off the parsed Document via an in-memory pending-imports map that is cleared immediately after setDocument succeeds to avoid re-application on partial failure - Export as DOCX toolbar button in the body formatting toolbar - EditorAPI.resetAfterDocumentReplace() resets cursor, selection, layout cache, and edit context after external document replacement Infrastructure: - MinIO service in docker-compose.yaml (ports 9000/9001) - @types/multer dev dependency for Express.Multer.File typing - knip.json excludes .claude/** so dead-code scans do not trip over sibling git worktrees Non-goals: floating/anchored images, nested tables (flattened to text), form controls, SmartArt, comments, track changes, and pixel-perfect round-trip fidelity with Word. Design: docs/design/docs/docs-docx-import-export.md Plan: docs/tasks/active/20260410-docx-import-export-todo.md Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
v0.3.2 is a deploy release (not tag-only) because #114 adds a backend ImageModule that requires IMAGE_STORAGE_* env vars at runtime. Plan covers AWS provisioning (wafflebase bucket + IAM user), wafflebase repo bump/tag/release, and devops repo PR. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
v0.3.2 is a deploy release (not tag-only) because #114 adds a backend ImageModule that requires IMAGE_STORAGE_* env vars at runtime. Plan covers AWS provisioning (wafflebase bucket + IAM user), wafflebase repo bump/tag/release, and devops repo PR. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
DOCX import/export shipped in PR #114 (merged to main as bbce164 on 2026-04-11) as part of v0.3.2. The 20260410-docx-import-export-todo.md plan was the execution blueprint for that work — every task from Phase 1 prerequisite features through Phase 4 frontend integration exists in the codebase, but only the last five checkboxes were ever ticked because the execution pipeline ran end-to-end faster than the manual checkbox update. The plan is accurate in spirit; mark all items complete and move it to archive/2026/04/. Also regenerate docs/tasks/archive/README.md via `pnpm tasks:index`. The index picks up two entries that were physically archived in PR #110 (header/footer and page break task plans) but never made it into the README because the index regenerator wasn't run at that time. Restoring them now keeps the published archive list consistent with the filesystem. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Summary
Document, DocxExporter writes aDocumentback to a .docx Blob. Frontend wires both into the document list "New" dropdown and the body toolbar.Design and Plan
docs/design/docs/docs-docx-import-export.mddocs/tasks/active/20260410-docx-import-export-todo.mdWhat's Included
Phase 1 — Prerequisites
ImageDatatype onInlineStyle;Doc.insertImageInlinevia a newapplyInsertInlinehelper inblock-helpers.ts; Yorkie image serializationlayout.ts,doc-canvas.ts, andtable-layout.tswith a shared image cache and async-load re-renderImageModuleNestJS backend with S3/MinIO storage, JWT-authed upload/delete, public serve with UUID-format validation, bucket auto-createPhase 2 — DOCX Import
DocxImporter.import(buffer, imageUploader?)— handles paragraphs, styled runs, tables withgridSpan/vMerge, nested-table flattening to text, page setup, headers/footers, and image upload hand-offPhase 3 — DOCX Export
DocxExporter.export(doc, imageFetcher?)— builds document.xml, header1.xml, footer1.xml, relationships, and packages to a Blob via JSZip. Round-trip tested against the importer.Phase 4 — Frontend integration
EditorAPI.resetAfterDocumentReplace()to fix a stale-cursor crash afterstore.setDocument()applies the imported contentInfrastructure
docker-compose.yaml(ports 9000/9001,minio-datavolume)@types/multerdev dep so the image controller typechecks under watch mode.claude/**so dead-code scans don't trip over sibling git worktreesIntentional Non-Goals
Test plan
pnpm verify:fast— 434 tests across 28 test files passpnpm verify:self— all build, lint, typecheck, chunk, and entropy lanes passImageDatatype,insertImageInline(including table-cell path), OOXML unit conversions, OOXML ↔ Docs style mapping, XML parsers, DocxImporter (including image dimensions, stacked vMerge groups, deeply nested table de-duplication), DocxExporter round-trips, font fallback chainsdocker compose up -d(with MinIO),pnpm dev, import the target form.docx via the document list "New" dropdown and verify layout, tables, page setup, and embedded images renderFollow-Ups
Tracked from code review; not in scope for this PR:
IMAGE_STORAGE_*env vars documented inpackages/backend/README.mdtable-layout.ts; consider hoistingmeasureSegmentsinto a shared helper🤖 Generated with Claude Code
Summary by CodeRabbit