Skip to content

Add DOCX import and export for the docs editor#114

Merged
hackerwins merged 30 commits into
mainfrom
feature/docx-import-export
Apr 10, 2026
Merged

Add DOCX import and export for the docs editor#114
hackerwins merged 30 commits into
mainfrom
feature/docx-import-export

Conversation

@hackerwins

@hackerwins hackerwins commented Apr 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Adds bidirectional Microsoft Word (.docx) import and export to the docs editor, including inline images, tables with cell merge, page setup, and headers/footers.
  • Introduces the prerequisite features needed to represent real-world .docx content: inline image support in the model/layout/canvas, an S3-compatible image service backed by MinIO in dev, and a Korean web-font registry with fallback chains.
  • Converts at the model layer: DocxImporter reads a .docx into a Document, DocxExporter writes a Document back to a .docx Blob. Frontend wires both into the document list "New" dropdown and the body toolbar.

Design and Plan

  • Design: docs/design/docs/docs-docx-import-export.md
  • Implementation plan: docs/tasks/active/20260410-docx-import-export-todo.md

What's Included

Phase 1 — Prerequisites

  • ImageData type on InlineStyle; Doc.insertImageInline via a new applyInsertInline helper in block-helpers.ts; Yorkie image serialization
  • Image measurement/rendering in layout.ts, doc-canvas.ts, and table-layout.ts with a shared image cache and async-load re-render
  • ImageModule NestJS backend with S3/MinIO storage, JWT-authed upload/delete, public serve with UUID-format validation, bucket auto-create
  • Font registry with fallback chains for Korean typefaces (맑은 고딕, 바탕, HY헤드라인M) and the browser Font Loading API

Phase 2 — DOCX Import

  • Unit conversions (twips/EMU/half-points ↔ CSS px)
  • OOXML → Docs style mapping (run, paragraph, table cell, highlight)
  • XML parser utilities (relationships, paragraphs, page setup)
  • DocxImporter.import(buffer, imageUploader?) — handles paragraphs, styled runs, tables with gridSpan/vMerge, nested-table flattening to text, page setup, headers/footers, and image upload hand-off

Phase 3 — DOCX Export

  • Docs → OOXML style mapping
  • OOXML templates for content types, rels, and styles
  • 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

  • "Import DOCX" item in the New dropdown (document list + empty state) that parses the file, uploads embedded images to the image service, creates a new document, and hands off the parsed Document via an in-memory pending-imports map
  • "Export as DOCX" toolbar button in the body formatting toolbar
  • EditorAPI.resetAfterDocumentReplace() to fix a stale-cursor crash after store.setDocument() applies the imported content

Infrastructure

  • MinIO service added to docker-compose.yaml (ports 9000/9001, minio-data volume)
  • @types/multer dev dep so the image controller typechecks under watch mode
  • Knip config excludes .claude/** so dead-code scans don't trip over sibling git worktrees

Intentional Non-Goals

  • Floating / anchored images — only inline images are supported
  • Nested tables — content is flattened to text on import (documented in design)
  • Form controls, SmartArt, comments, track changes, footnotes
  • Pixel-perfect round-trip fidelity with Word — structural correctness first

Test plan

  • pnpm verify:fast — 434 tests across 28 test files pass
  • pnpm verify:self — all build, lint, typecheck, chunk, and entropy lanes pass
  • Unit tests for ImageData type, 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 chains
  • Manual: docker 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 render
  • Manual: export a doc with text formatting, tables, and images, open it in Word / Google Docs / LibreOffice

Follow-Ups

Tracked from code review; not in scope for this PR:

  • Bounded LRU eviction for the canvas image cache
  • Baseline-aligned image placement (currently bottom-aligned)
  • Import progress toast / loading state
  • Backend IMAGE_STORAGE_* env vars documented in packages/backend/README.md
  • Images inside tables pass through a parallel code path in table-layout.ts; consider hoisting measureSegments into a shared helper

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • DOCX import/export (preserves formatting, tables, headers/footers)
    • Inline image support: insert, upload, render, and automatic scaling
    • Image service integration for uploading and serving images (local dev storage enabled)
    • UI: "Import DOCX" and "Export as DOCX" flows with direct download
    • Pending import/resume: imported documents can be applied after navigation
    • Korean web font loading for improved typography and rendering

hackerwins and others added 22 commits April 10, 2026 13:40
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]>
@coderabbitai

coderabbitai Bot commented Apr 10, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds 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

Cohort / File(s) Summary
Infrastructure
docker-compose.yaml, knip.json
Adds minio service + minio-data volume to docker-compose; ignores .claude/** in knip.json.
Backend Image Service
packages/backend/...
packages/backend/src/image/image.config.ts, .../image.controller.ts, .../image.module.ts, .../image.service.ts, packages/backend/src/app.module.ts, packages/backend/package.json
New ImageModule with S3/MinIO config, upload/get/delete endpoints, bucket init logic; adds @aws-sdk/client-s3 and multer typings; registers module in AppModule.
Docs Import/Export Core
packages/docs/src/import/..., packages/docs/src/export/..., packages/docs/src/export/docx-templates.ts, packages/docs/package.json, packages/docs/src/index.ts
New DocxImporter/DocxExporter, XML parsing/mapping, OOXML templates, unit conversion helpers, JSZip dependency, and public exports for importer/exporter and image types.
Model & Store
packages/docs/src/model/types.ts, packages/docs/src/model/document.ts, packages/docs/src/store/block-helpers.ts
Adds ImageData type and InlineStyle.image; updates inline equality; adds Doc.insertImageInline and pure applyInsertInline helper; updates block/table update path handling.
Rendering & Layout
packages/docs/src/view/doc-canvas.ts, packages/docs/src/view/layout.ts, packages/docs/src/view/table-layout.ts, packages/docs/src/view/fonts.ts
Canvas image cache and async load + request-render hook, layout changes to treat image inlines as unbreakable and affecting line height/scaling, and a FontRegistry + font-family resolution mapping.
Editor & Frontend Integration
packages/docs/src/view/editor.ts, packages/frontend/src/app/docs/*
docx-actions.ts, pending-imports.ts, docs-formatting-toolbar.tsx, docs-view.tsx, docs-detail.tsx, document-list.tsx
Frontend image uploader/fetcher, pick/import/export functions, pending-import registry, toolbar export button, DocsView import-apply flow, and prop passthrough for document id/title.
Yorkie Store Serialization
packages/frontend/src/app/docs/yorkie-doc-store.ts
Serializes/deserializes style.image into Yorkie string attributes and reconstructs ImageData when parsing.
Style Mapping Helpers
packages/docs/src/import/docx-style-map.ts, packages/docs/src/export/docx-style-map.ts
Import-side mapping from Word XML → internal styles; export-side run/paragraph DOCX XML builders.
Tests
packages/docs/test/...
New test suites for importer, exporter, parser, style maps, units, model image behaviors, fonts; exporter↔importer round-trip tests including image/media packaging.
Docs & Plan
docs/design/docs-docx-import-export.md, docs/tasks/active/20260410-docx-import-export-todo.md
Design doc and multi-phase implementation plan describing architecture, flows, and tasks.

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
Loading
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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 I hop through ZIPs, stitch pixels in a row,
Upload to buckets where tiny images go.
Fonts learn new tunes, canvases wake and play,
DOCX blooms outward — carrot-coded day! 🥕✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add DOCX import and export for the docs editor' clearly and concisely summarizes the main change—adding bidirectional DOCX support to the docs editor. It directly reflects the primary objective and main feature introduced.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/docx-import-export

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 | 🟡 Minor

Update file-structure docs to match the implemented exporter helper file.

The table lists docx-builder.ts, but this PR’s export helper is docx-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 | 🟡 Minor

Add 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.md file 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.md and YYYYMMDD-<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 | 🟡 Minor

Avoid style object aliasing when inserting inline nodes.

{ ...inline } is a shallow copy; inline.style (and nested style.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 | 🟡 Minor

Cover 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 | 🟡 Minor

Add a header/footer import fixture.

The PR adds header/footer support, but this suite never exercises header*.xml / footer*.xml relationships or asserts that imported blocks land in doc.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 | 🟡 Minor

Unit-test the drawing/image branch of parseParagraph.

This file only covers text runs and empty paragraphs, but parseParagraph also 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 | 🟡 Minor

Apply the fallback after sanitizing the title.

If title is only whitespace or illegal filename characters, safeTitle becomes empty and the browser download name becomes .docx. Reapply the "document" fallback after replace(...).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 | 🟡 Minor

Escape fontFamily before writing it into XML attributes.

style.fontFamily is inserted verbatim into w: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, '&amp;')
+    .replace(/"/g, '&quot;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;');
+}
+
 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 from onFontLoaded.

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 for ensureFont state transitions and callback behavior.

Current coverage validates mapping and default state only; please add edge-case tests for loaded/error transitions 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 src divergence; add explicit assertions for width, height, and alt differences 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:9001 plus hard-coded minioadmin publishes the S3 API and console on all interfaces. For a dev-only service, bind these ports to 127.0.0.1 and/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

📥 Commits

Reviewing files that changed from the base of the PR and between 5f2ea04 and c2063b6.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (43)
  • docker-compose.yaml
  • docs/design/docs/docs-docx-import-export.md
  • docs/tasks/active/20260410-docx-import-export-todo.md
  • knip.json
  • packages/backend/package.json
  • packages/backend/src/app.module.ts
  • packages/backend/src/image/image.config.ts
  • packages/backend/src/image/image.controller.ts
  • packages/backend/src/image/image.module.ts
  • packages/backend/src/image/image.service.ts
  • packages/docs/package.json
  • packages/docs/src/export/docx-exporter.ts
  • packages/docs/src/export/docx-style-map.ts
  • packages/docs/src/export/docx-templates.ts
  • packages/docs/src/import/docx-importer.ts
  • packages/docs/src/import/docx-parser.ts
  • packages/docs/src/import/docx-style-map.ts
  • packages/docs/src/import/units.ts
  • packages/docs/src/index.ts
  • packages/docs/src/model/document.ts
  • packages/docs/src/model/types.ts
  • packages/docs/src/store/block-helpers.ts
  • packages/docs/src/view/doc-canvas.ts
  • packages/docs/src/view/editor.ts
  • packages/docs/src/view/fonts.ts
  • packages/docs/src/view/layout.ts
  • packages/docs/src/view/table-layout.ts
  • packages/docs/test/export/docx-exporter.test.ts
  • packages/docs/test/export/docx-style-map.test.ts
  • packages/docs/test/import/docx-importer.test.ts
  • packages/docs/test/import/docx-parser.test.ts
  • packages/docs/test/import/docx-style-map.test.ts
  • packages/docs/test/import/units.test.ts
  • packages/docs/test/model/document.test.ts
  • packages/docs/test/model/types.test.ts
  • packages/docs/test/view/fonts.test.ts
  • packages/frontend/src/app/docs/docs-detail.tsx
  • packages/frontend/src/app/docs/docs-formatting-toolbar.tsx
  • packages/frontend/src/app/docs/docs-view.tsx
  • packages/frontend/src/app/docs/docx-actions.ts
  • packages/frontend/src/app/docs/pending-imports.ts
  • packages/frontend/src/app/docs/yorkie-doc-store.ts
  • packages/frontend/src/app/documents/document-list.tsx

Comment thread packages/backend/src/image/image.config.ts Outdated
Comment thread packages/backend/src/image/image.service.ts Outdated
Comment thread packages/docs/src/export/docx-exporter.ts
Comment thread packages/docs/src/import/docx-importer.ts Outdated
Comment thread packages/docs/src/import/docx-importer.ts
Comment thread packages/docs/src/import/units.ts Outdated
Comment thread packages/docs/src/view/doc-canvas.ts
Comment thread packages/docs/src/view/fonts.ts Outdated
Comment thread packages/frontend/src/app/docs/docs-view.tsx Outdated
Comment thread packages/frontend/src/app/docs/yorkie-doc-store.ts
@github-actions

github-actions Bot commented Apr 10, 2026

Copy link
Copy Markdown
Contributor

Verification: verify:self

Result: ✅ PASS in 117.0s

Lane Status Duration
sheets:build ✅ pass 13.3s
docs:build ✅ pass 7.6s
verify:fast ✅ pass 59.1s
frontend:build ✅ pass 15.6s
verify:frontend:chunks ✅ pass 0.3s
backend:build ✅ pass 4.8s
cli:build ✅ pass 1.8s
verify:entropy ✅ pass 14.5s

Verification: verify:integration

Result: ✅ PASS

@codecov

codecov Bot commented Apr 10, 2026

Copy link
Copy Markdown

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (3)
packages/docs/test/import/docx-importer.test.ts (2)

383-395: Use toHaveLength for consistency with other array length assertions.

Minor consistency nit: Line 388 uses .length property while other tests use the toHaveLength matcher.

♻️ 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.

onFontLoaded currently 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

📥 Commits

Reviewing files that changed from the base of the PR and between c2063b6 and c33ca31.

📒 Files selected for processing (18)
  • packages/backend/src/image/image.config.ts
  • packages/backend/src/image/image.service.ts
  • packages/docs/src/export/docx-exporter.ts
  • packages/docs/src/export/docx-style-map.ts
  • packages/docs/src/import/docx-importer.ts
  • packages/docs/src/import/docx-parser.ts
  • packages/docs/src/import/docx-style-map.ts
  • packages/docs/src/import/units.ts
  • packages/docs/src/view/doc-canvas.ts
  • packages/docs/src/view/fonts.ts
  • packages/docs/test/export/docx-exporter.test.ts
  • packages/docs/test/import/docx-importer.test.ts
  • packages/docs/test/import/docx-parser.test.ts
  • packages/docs/test/import/docx-style-map.test.ts
  • packages/docs/test/import/units.test.ts
  • packages/frontend/src/app/docs/docs-view.tsx
  • packages/frontend/src/app/docs/pending-imports.ts
  • packages/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

Comment thread packages/docs/src/export/docx-exporter.ts Outdated
Comment thread packages/docs/src/export/docx-exporter.ts
Comment thread packages/docs/src/view/fonts.ts
Comment thread packages/frontend/src/app/docs/docs-view.tsx
- 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]>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 = 9000 assumption 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 await in a loop. For documents with many images, this could be slow. Parallel fetching with Promise.all would 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

📥 Commits

Reviewing files that changed from the base of the PR and between c33ca31 and 16ee201.

📒 Files selected for processing (6)
  • packages/docs/src/export/docx-exporter.ts
  • packages/docs/src/export/docx-templates.ts
  • packages/docs/src/view/fonts.ts
  • packages/docs/test/export/docx-exporter.test.ts
  • packages/docs/test/view/fonts.test.ts
  • packages/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

Comment on lines +179 to +187
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>`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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.

@hackerwins
hackerwins merged commit 7617ba7 into main Apr 10, 2026
4 checks passed
@hackerwins
hackerwins deleted the feature/docx-import-export branch April 10, 2026 23:21
hackerwins added a commit that referenced this pull request Apr 10, 2026
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]>
hackerwins added a commit that referenced this pull request Apr 11, 2026
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]>
@hackerwins hackerwins mentioned this pull request Apr 11, 2026
4 tasks
hackerwins added a commit that referenced this pull request Apr 11, 2026
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]>
hackerwins added a commit that referenced this pull request Apr 11, 2026
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]>
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