diff --git a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-advanced-tables-docx-1-chromium-linux.png b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-advanced-tables-docx-1-chromium-linux.png index 287225f097..d889308434 100644 Binary files a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-advanced-tables-docx-1-chromium-linux.png and b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-advanced-tables-docx-1-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-broken-list-missing-items-docx-1-chromium-linux.png b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-broken-list-missing-items-docx-1-chromium-linux.png index a2ae086de2..0374f43cf5 100644 Binary files a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-broken-list-missing-items-docx-1-chromium-linux.png and b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-broken-list-missing-items-docx-1-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-contract-acc-docx-1-chromium-linux.png b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-contract-acc-docx-1-chromium-linux.png index c7036fd608..210d11e6b0 100644 Binary files a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-contract-acc-docx-1-chromium-linux.png and b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-contract-acc-docx-1-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-custom-list-numbering-docx-1-chromium-linux.png b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-custom-list-numbering-docx-1-chromium-linux.png index 3b9d007275..6bcbe99b2c 100644 Binary files a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-custom-list-numbering-docx-1-chromium-linux.png and b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-custom-list-numbering-docx-1-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-custom-list1-docx-1-chromium-linux.png b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-custom-list1-docx-1-chromium-linux.png index 7f560aebc3..55d4df6add 100644 Binary files a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-custom-list1-docx-1-chromium-linux.png and b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-custom-list1-docx-1-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-exported-list-font-docx-1-chromium-linux.png b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-exported-list-font-docx-1-chromium-linux.png index b9b1f8b72f..3879860e97 100644 Binary files a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-exported-list-font-docx-1-chromium-linux.png and b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-exported-list-font-docx-1-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-features-lists-docx-1-chromium-linux.png b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-features-lists-docx-1-chromium-linux.png index 8e364a5dc1..3e62554cb5 100644 Binary files a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-features-lists-docx-1-chromium-linux.png and b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-features-lists-docx-1-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-invalid-list-def-fallback-docx-1-chromium-linux.png b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-invalid-list-def-fallback-docx-1-chromium-linux.png index 8f5739f5fb..67a8fcaeb9 100644 Binary files a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-invalid-list-def-fallback-docx-1-chromium-linux.png and b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-invalid-list-def-fallback-docx-1-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-list-formatting-indents-docx-1-chromium-linux.png b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-list-formatting-indents-docx-1-chromium-linux.png index 7ff11ac3cc..dc2ddcb961 100644 Binary files a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-list-formatting-indents-docx-1-chromium-linux.png and b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-list-formatting-indents-docx-1-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-list-with-table-break-docx-1-chromium-linux.png b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-list-with-table-break-docx-1-chromium-linux.png index ea03573127..d48bf1bc17 100644 Binary files a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-list-with-table-break-docx-1-chromium-linux.png and b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-list-with-table-break-docx-1-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-msa-list-base-indent-docx-1-chromium-linux.png b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-msa-list-base-indent-docx-1-chromium-linux.png index 6e9ee4ef78..8b55a25e21 100644 Binary files a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-msa-list-base-indent-docx-1-chromium-linux.png and b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-msa-list-base-indent-docx-1-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-ooxml-bold-rstyle-linked-combos-demo-docx-1-chromium-linux.png b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-ooxml-bold-rstyle-linked-combos-demo-docx-1-chromium-linux.png index e3261949f5..1f3a4a25d4 100644 Binary files a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-ooxml-bold-rstyle-linked-combos-demo-docx-1-chromium-linux.png and b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-ooxml-bold-rstyle-linked-combos-demo-docx-1-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-ooxml-color-rstyle-linked-combos-demo-docx-1-chromium-linux.png b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-ooxml-color-rstyle-linked-combos-demo-docx-1-chromium-linux.png index 91a30c7306..ef61915a49 100644 Binary files a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-ooxml-color-rstyle-linked-combos-demo-docx-1-chromium-linux.png and b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-ooxml-color-rstyle-linked-combos-demo-docx-1-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-ooxml-highlight-rstyle-linked-combos-demo-docx-1-chromium-linux.png b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-ooxml-highlight-rstyle-linked-combos-demo-docx-1-chromium-linux.png index 083d3aa092..d876ed5553 100644 Binary files a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-ooxml-highlight-rstyle-linked-combos-demo-docx-1-chromium-linux.png and b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-ooxml-highlight-rstyle-linked-combos-demo-docx-1-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-ooxml-italic-rstyle-combos-demo-docx-1-chromium-linux.png b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-ooxml-italic-rstyle-combos-demo-docx-1-chromium-linux.png index 48f0be7e22..0e3544ec1f 100644 Binary files a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-ooxml-italic-rstyle-combos-demo-docx-1-chromium-linux.png and b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-ooxml-italic-rstyle-combos-demo-docx-1-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-ooxml-rFonts-rstyle-linked-combos-demo-docx-1-chromium-linux.png b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-ooxml-rFonts-rstyle-linked-combos-demo-docx-1-chromium-linux.png index 71f4fe750c..320df3fc8e 100644 Binary files a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-ooxml-rFonts-rstyle-linked-combos-demo-docx-1-chromium-linux.png and b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-ooxml-rFonts-rstyle-linked-combos-demo-docx-1-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-ooxml-size-rstyle-linked-combos-demo-docx-1-chromium-linux.png b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-ooxml-size-rstyle-linked-combos-demo-docx-1-chromium-linux.png index d62e82e498..1cfdc807c1 100644 Binary files a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-ooxml-size-rstyle-linked-combos-demo-docx-1-chromium-linux.png and b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-ooxml-size-rstyle-linked-combos-demo-docx-1-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-ooxml-strike-rstyle-linked-combos-demo-docx-1-chromium-linux.png b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-ooxml-strike-rstyle-linked-combos-demo-docx-1-chromium-linux.png index 2f3b1e7684..e6fce0c7b9 100644 Binary files a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-ooxml-strike-rstyle-linked-combos-demo-docx-1-chromium-linux.png and b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-ooxml-strike-rstyle-linked-combos-demo-docx-1-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-ooxml-underline-rstyle-linked-combos-demo-docx-1-chromium-linux.png b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-ooxml-underline-rstyle-linked-combos-demo-docx-1-chromium-linux.png index b0dc66c474..b10183b3c9 100644 Binary files a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-ooxml-underline-rstyle-linked-combos-demo-docx-1-chromium-linux.png and b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-ooxml-underline-rstyle-linked-combos-demo-docx-1-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-restart-numbering-sub-list-docx-1-chromium-linux.png b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-restart-numbering-sub-list-docx-1-chromium-linux.png index 56bfc2b755..9d8983e2f3 100644 Binary files a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-restart-numbering-sub-list-docx-1-chromium-linux.png and b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-restart-numbering-sub-list-docx-1-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-sublist-issue-docx-1-chromium-linux.png b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-sublist-issue-docx-1-chromium-linux.png index e6d6b67ec9..34c930b50e 100644 Binary files a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-sublist-issue-docx-1-chromium-linux.png and b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-sublist-issue-docx-1-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-table-width-issue-docx-1-chromium-linux.png b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-table-width-issue-docx-1-chromium-linux.png index f87c3464bb..eba3294a6e 100644 Binary files a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-table-width-issue-docx-1-chromium-linux.png and b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-table-width-issue-docx-1-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-table-widths-SD-732-docx-1-chromium-linux.png b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-table-widths-SD-732-docx-1-chromium-linux.png index 109d0a97ff..da337eeaf7 100644 Binary files a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-table-widths-SD-732-docx-1-chromium-linux.png and b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-table-widths-SD-732-docx-1-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-table-with-block-bookmarks-docx-1-chromium-linux.png b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-table-with-block-bookmarks-docx-1-chromium-linux.png index 58dc4fd26f..fb98cd27b7 100644 Binary files a/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-table-with-block-bookmarks-docx-1-chromium-linux.png and b/e2e-tests/tests/visuals/basic-documents.spec.js-snapshots/basic-documents-table-with-block-bookmarks-docx-1-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/comments.spec.js-snapshots/viewing-mode-comments-visibility-comments-visible-when-enabled-in-viewing-mode-1-chromium-linux.png b/e2e-tests/tests/visuals/comments.spec.js-snapshots/viewing-mode-comments-visibility-comments-visible-when-enabled-in-viewing-mode-1-chromium-linux.png index 8b4400cdcc..f4c8070d85 100644 Binary files a/e2e-tests/tests/visuals/comments.spec.js-snapshots/viewing-mode-comments-visibility-comments-visible-when-enabled-in-viewing-mode-1-chromium-linux.png and b/e2e-tests/tests/visuals/comments.spec.js-snapshots/viewing-mode-comments-visibility-comments-visible-when-enabled-in-viewing-mode-1-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-basic-list-chromium-linux.png b/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-basic-list-chromium-linux.png index c4348f61b6..7d9755d4a1 100644 Binary files a/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-basic-list-chromium-linux.png and b/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-basic-list-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-change-list-type-chromium-linux.png b/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-change-list-type-chromium-linux.png index adf6ef6a15..67ba7aeea1 100644 Binary files a/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-change-list-type-chromium-linux.png and b/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-change-list-type-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-change-sublist-type-chromium-linux.png b/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-change-sublist-type-chromium-linux.png index 2578264525..474e5079a8 100644 Binary files a/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-change-sublist-type-chromium-linux.png and b/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-change-sublist-type-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-enter-after-list-chromium-linux.png b/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-enter-after-list-chromium-linux.png index 3465e42e0f..3dc40b9843 100644 Binary files a/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-enter-after-list-chromium-linux.png and b/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-enter-after-list-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-enter-before-list-chromium-linux.png b/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-enter-before-list-chromium-linux.png index 42ca4c06b9..0b40a571c3 100644 Binary files a/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-enter-before-list-chromium-linux.png and b/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-enter-before-list-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-enter-inside-list-chromium-linux.png b/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-enter-inside-list-chromium-linux.png index 92ec5d6f84..09b7759829 100644 Binary files a/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-enter-inside-list-chromium-linux.png and b/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-enter-inside-list-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-indent-list-chromium-linux.png b/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-indent-list-chromium-linux.png index 40d75be492..cf67aaef49 100644 Binary files a/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-indent-list-chromium-linux.png and b/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-indent-list-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-inline-images-within-lists-chromium-linux.png b/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-inline-images-within-lists-chromium-linux.png index c118317134..5eaaac2e37 100644 Binary files a/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-inline-images-within-lists-chromium-linux.png and b/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-inline-images-within-lists-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-list-and-delete-item-chromium-linux.png b/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-list-and-delete-item-chromium-linux.png index 23df33d077..1336823589 100644 Binary files a/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-list-and-delete-item-chromium-linux.png and b/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-list-and-delete-item-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-lists-in-table-cells-chromium-linux.png b/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-lists-in-table-cells-chromium-linux.png index 96aabf1317..a31bf0df90 100644 Binary files a/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-lists-in-table-cells-chromium-linux.png and b/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-lists-in-table-cells-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-nested-list-chromium-linux.png b/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-nested-list-chromium-linux.png index 40d75be492..9b2c872a85 100644 Binary files a/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-nested-list-chromium-linux.png and b/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-nested-list-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-nested-list-with-sublist-chromium-linux.png b/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-nested-list-with-sublist-chromium-linux.png index 818a08ca46..f86efc0488 100644 Binary files a/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-nested-list-with-sublist-chromium-linux.png and b/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-nested-list-with-sublist-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-paste-content-into-lists-chromium-linux.png b/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-paste-content-into-lists-chromium-linux.png index 94cf58fdd3..bdbf8e2cf6 100644 Binary files a/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-paste-content-into-lists-chromium-linux.png and b/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-paste-content-into-lists-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-press-enter-twice-and-exit-the-list-chromium-linux.png b/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-press-enter-twice-and-exit-the-list-chromium-linux.png index 9b8042b285..0bad3668ab 100644 Binary files a/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-press-enter-twice-and-exit-the-list-chromium-linux.png and b/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-press-enter-twice-and-exit-the-list-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-shift-tab-chromium-linux.png b/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-shift-tab-chromium-linux.png index 23df33d077..d23ad64ef5 100644 Binary files a/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-shift-tab-chromium-linux.png and b/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-shift-tab-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-table-in-list-chromium-linux.png b/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-table-in-list-chromium-linux.png index c9e096cc1c..2ac5d7b883 100644 Binary files a/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-table-in-list-chromium-linux.png and b/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-table-in-list-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-turn-text-into-list-chromium-linux.png b/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-turn-text-into-list-chromium-linux.png index e8e00b991c..0d95e3e161 100644 Binary files a/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-turn-text-into-list-chromium-linux.png and b/e2e-tests/tests/visuals/lists.spec.js-snapshots/ordered-turn-text-into-list-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-basic-list-chromium-linux.png b/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-basic-list-chromium-linux.png index 0dd2f39ed5..ad4cf4e6e4 100644 Binary files a/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-basic-list-chromium-linux.png and b/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-basic-list-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-change-list-type-chromium-linux.png b/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-change-list-type-chromium-linux.png index b23706dc76..53acdcce75 100644 Binary files a/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-change-list-type-chromium-linux.png and b/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-change-list-type-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-change-sublist-type-chromium-linux.png b/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-change-sublist-type-chromium-linux.png index c207a1018e..4e1a1bb889 100644 Binary files a/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-change-sublist-type-chromium-linux.png and b/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-change-sublist-type-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-enter-after-list-chromium-linux.png b/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-enter-after-list-chromium-linux.png index 9d2e32d589..0a1acf98f7 100644 Binary files a/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-enter-after-list-chromium-linux.png and b/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-enter-after-list-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-enter-before-list-chromium-linux.png b/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-enter-before-list-chromium-linux.png index 5264e98997..2ba95bdf4d 100644 Binary files a/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-enter-before-list-chromium-linux.png and b/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-enter-before-list-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-enter-inside-list-chromium-linux.png b/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-enter-inside-list-chromium-linux.png index 4f408cf792..b6d23aea48 100644 Binary files a/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-enter-inside-list-chromium-linux.png and b/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-enter-inside-list-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-indent-list-chromium-linux.png b/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-indent-list-chromium-linux.png index cd5a5fa12d..05db7743ae 100644 Binary files a/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-indent-list-chromium-linux.png and b/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-indent-list-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-inline-images-within-lists-chromium-linux.png b/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-inline-images-within-lists-chromium-linux.png index de929de8f9..55dd2531d2 100644 Binary files a/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-inline-images-within-lists-chromium-linux.png and b/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-inline-images-within-lists-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-list-and-delete-item-chromium-linux.png b/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-list-and-delete-item-chromium-linux.png index 9cdb18e286..9387a230cf 100644 Binary files a/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-list-and-delete-item-chromium-linux.png and b/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-list-and-delete-item-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-lists-in-table-cells-chromium-linux.png b/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-lists-in-table-cells-chromium-linux.png index 834354e53e..6c06fe5f1f 100644 Binary files a/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-lists-in-table-cells-chromium-linux.png and b/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-lists-in-table-cells-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-nested-list-chromium-linux.png b/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-nested-list-chromium-linux.png index cd5a5fa12d..05db7743ae 100644 Binary files a/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-nested-list-chromium-linux.png and b/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-nested-list-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-nested-list-with-sublist-chromium-linux.png b/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-nested-list-with-sublist-chromium-linux.png index 392a955ac4..2c5c5f06ba 100644 Binary files a/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-nested-list-with-sublist-chromium-linux.png and b/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-nested-list-with-sublist-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-paste-content-into-lists-chromium-linux.png b/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-paste-content-into-lists-chromium-linux.png index 6afc6691a0..8c47001de3 100644 Binary files a/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-paste-content-into-lists-chromium-linux.png and b/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-paste-content-into-lists-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-press-enter-twice-and-exit-the-list-chromium-linux.png b/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-press-enter-twice-and-exit-the-list-chromium-linux.png index f1f205ec7e..3b98579ff3 100644 Binary files a/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-press-enter-twice-and-exit-the-list-chromium-linux.png and b/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-press-enter-twice-and-exit-the-list-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-shift-tab-chromium-linux.png b/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-shift-tab-chromium-linux.png index 9cdb18e286..b7142757be 100644 Binary files a/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-shift-tab-chromium-linux.png and b/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-shift-tab-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-table-in-list-chromium-linux.png b/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-table-in-list-chromium-linux.png index fc10a4803e..0cad057efe 100644 Binary files a/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-table-in-list-chromium-linux.png and b/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-table-in-list-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-turn-text-into-list-chromium-linux.png b/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-turn-text-into-list-chromium-linux.png index 4d54b39f8d..34db0e9b59 100644 Binary files a/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-turn-text-into-list-chromium-linux.png and b/e2e-tests/tests/visuals/lists.spec.js-snapshots/unordered-turn-text-into-list-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/structured-content-commands.spec.js-snapshots/structured-content-commands-advanced-use-cases-04600-tructured-content-inline-inside-a-nested-list-1-chromium-linux.png b/e2e-tests/tests/visuals/structured-content-commands.spec.js-snapshots/structured-content-commands-advanced-use-cases-04600-tructured-content-inline-inside-a-nested-list-1-chromium-linux.png index 3fa6b80d18..7dcb697cb3 100644 Binary files a/e2e-tests/tests/visuals/structured-content-commands.spec.js-snapshots/structured-content-commands-advanced-use-cases-04600-tructured-content-inline-inside-a-nested-list-1-chromium-linux.png and b/e2e-tests/tests/visuals/structured-content-commands.spec.js-snapshots/structured-content-commands-advanced-use-cases-04600-tructured-content-inline-inside-a-nested-list-1-chromium-linux.png differ diff --git a/e2e-tests/tests/visuals/structured-content-commands.spec.js-snapshots/structured-content-commands-advanced-use-cases-a706e-nsert-structured-content-inline-inside-a-list-1-chromium-linux.png b/e2e-tests/tests/visuals/structured-content-commands.spec.js-snapshots/structured-content-commands-advanced-use-cases-a706e-nsert-structured-content-inline-inside-a-list-1-chromium-linux.png index e952360807..f431c5e981 100644 Binary files a/e2e-tests/tests/visuals/structured-content-commands.spec.js-snapshots/structured-content-commands-advanced-use-cases-a706e-nsert-structured-content-inline-inside-a-list-1-chromium-linux.png and b/e2e-tests/tests/visuals/structured-content-commands.spec.js-snapshots/structured-content-commands-advanced-use-cases-a706e-nsert-structured-content-inline-inside-a-list-1-chromium-linux.png differ diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 2e548aa770..ac89f3f3e4 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -1170,7 +1170,7 @@ export type WordLayoutConfig = { export type ParagraphAttrs = { styleId?: string; - alignment?: 'left' | 'center' | 'right' | 'justify' | 'both'; + alignment?: 'left' | 'center' | 'right' | 'justify'; spacing?: ParagraphSpacing; /** * Indicates which spacing properties were explicitly set on the paragraph. @@ -1197,7 +1197,7 @@ export type ParagraphAttrs = { */ dropCapDescriptor?: DropCapDescriptor; frame?: ParagraphFrame; - numberingProperties?: Record; + numberingProperties?: { ilvl?: number; numId?: number } | null; borders?: ParagraphBorders; shading?: ParagraphShading; tabs?: TabStop[]; @@ -1205,6 +1205,7 @@ export type ParagraphAttrs = { tabIntervalTwips?: number; keepNext?: boolean; keepLines?: boolean; + pageBreakBefore?: boolean; trackedChangesMode?: TrackedChangesMode; trackedChangesEnabled?: boolean; /** Marks an empty paragraph that only exists to carry section properties. */ diff --git a/packages/layout-engine/layout-bridge/src/index.ts b/packages/layout-engine/layout-bridge/src/index.ts index 8e969926c4..2282cc2ccc 100644 --- a/packages/layout-engine/layout-bridge/src/index.ts +++ b/packages/layout-engine/layout-bridge/src/index.ts @@ -980,7 +980,7 @@ export function clickToPosition( const markerWidth = fragment.markerWidth ?? measure.marker?.markerWidth ?? 0; const isListItem = markerWidth > 0; const paraAlignment = block.attrs?.alignment; - const isJustified = paraAlignment === 'justify' || paraAlignment === 'both'; + const isJustified = paraAlignment === 'justify'; const alignmentOverride = isListItem && !isJustified ? 'left' : undefined; const pos = mapPointToPm(block, line, pageRelativePoint.x - fragment.x, isRTL, availableWidth, alignmentOverride); @@ -1089,7 +1089,7 @@ export function clickToPosition( const cellMarkerWidth = cellMeasure.marker?.markerWidth ?? 0; const isListItem = cellMarkerWidth > 0; const cellAlignment = cellBlock.attrs?.alignment; - const isJustified = cellAlignment === 'justify' || cellAlignment === 'both'; + const isJustified = cellAlignment === 'justify'; const alignmentOverride = isListItem && !isJustified ? 'left' : undefined; const pos = mapPointToPm(cellBlock, line, localX, isRTL, availableWidth, alignmentOverride); @@ -1412,7 +1412,7 @@ export function selectionToRects( // List items use textAlign: 'left' in the DOM for non-justify alignments. // For justify, we don't override so justify selection rectangles are calculated correctly. const blockAlignment = block.attrs?.alignment; - const isJustified = blockAlignment === 'justify' || blockAlignment === 'both'; + const isJustified = blockAlignment === 'justify'; const alignmentOverride = isListItemFlag && !isJustified ? 'left' : undefined; const startX = mapPmToX(block, line, charOffsetFrom, fragment.width, alignmentOverride); const endX = mapPmToX(block, line, charOffsetTo, fragment.width, alignmentOverride); diff --git a/packages/layout-engine/layout-bridge/src/remeasure.ts b/packages/layout-engine/layout-bridge/src/remeasure.ts index ead21a41c9..8f331a9df8 100644 --- a/packages/layout-engine/layout-bridge/src/remeasure.ts +++ b/packages/layout-engine/layout-bridge/src/remeasure.ts @@ -1079,13 +1079,7 @@ export function remeasureParagraph( // Both values represent the same concept: where the first-line text begins after the marker/tab. // IMPORTANT: Priority must match the painter (renderer.ts) which prefers marker.textStartX // because it's consistent with marker.markerX positioning. Mismatched priority causes justify overflow. - const markerTextStartX = wordLayout?.marker?.textStartX; - const textStartPx = - typeof markerTextStartX === 'number' && Number.isFinite(markerTextStartX) - ? markerTextStartX - : typeof wordLayout?.textStartPx === 'number' && Number.isFinite(wordLayout.textStartPx) - ? wordLayout.textStartPx - : undefined; + const textStartPx = wordLayout?.textStartPx; // Track measured marker text width for returning in measure.marker let measuredMarkerTextWidth: number | undefined; const resolvedTextStartPx = resolveListTextStartPx( @@ -1319,7 +1313,7 @@ export function remeasureParagraph( const marker = wordLayout?.marker; const markerInfo = marker ? { - markerWidth: marker.markerBoxWidthPx ?? indentHanging ?? 0, + markerWidth: indentHanging ?? 0, markerTextWidth: measuredMarkerTextWidth ?? 0, indentLeft, gutterWidth: marker.gutterWidthPx, diff --git a/packages/layout-engine/layout-engine/src/layout-paragraph.test.ts b/packages/layout-engine/layout-engine/src/layout-paragraph.test.ts index d1b7c1a251..3726e39bae 100644 --- a/packages/layout-engine/layout-engine/src/layout-paragraph.test.ts +++ b/packages/layout-engine/layout-engine/src/layout-paragraph.test.ts @@ -203,10 +203,10 @@ describe('layoutParagraphBlock - remeasurement with list markers', () => { expect(remeasureParagraph).toHaveBeenCalledWith(block, 150, 24); }); - it('uses fallback to markerBoxWidthPx when markerWidth is missing', () => { + it('uses markerWidth=0 fallback when markerWidth is missing', () => { const remeasureParagraph = vi.fn((block, maxWidth, firstLineIndent) => { - // Should use markerBoxWidthPx (20) + gutterWidth (6) - expect(firstLineIndent).toBe(26); + // markerWidth defaults to 0 when the measure marker is present + expect(firstLineIndent).toBe(6); return makeMeasure([{ width: 100, lineHeight: 20, maxWidth: 150 }]); }); @@ -226,7 +226,7 @@ describe('layoutParagraphBlock - remeasurement with list markers', () => { const measure = makeMeasure( [{ width: 100, lineHeight: 20, maxWidth: 200 }], - { gutterWidth: 6 }, // markerWidth is missing + { gutterWidth: 6 }, // markerWidth is missing and defaults to 0 ); const ctx: ParagraphLayoutContext = { @@ -242,7 +242,7 @@ describe('layoutParagraphBlock - remeasurement with list markers', () => { layoutParagraphBlock(ctx); - expect(remeasureParagraph).toHaveBeenCalledWith(block, 150, 26); + expect(remeasureParagraph).toHaveBeenCalledWith(block, 150, 6); }); it('uses fallback to 0 when both markerWidth and markerBoxWidthPx are missing', () => { diff --git a/packages/layout-engine/layout-engine/src/layout-table.test.ts b/packages/layout-engine/layout-engine/src/layout-table.test.ts index 92491fce0b..9a286b75c2 100644 --- a/packages/layout-engine/layout-engine/src/layout-table.test.ts +++ b/packages/layout-engine/layout-engine/src/layout-table.test.ts @@ -1439,13 +1439,48 @@ describe('layoutTableBlock', () => { const measure = createMockTableMeasure( [100, 100], // Two columns [120], // Row height (max of cell heights) - [ - // Row 0 - different line heights per cell - [20, 20, 20], // Cell 0: 3x20px lines - [40, 40, 40], // Cell 1: 3x40px lines - ], + [[20, 20, 20]], // Row 0 defaults (applied to all cells) ); + if (measure.rows[0].cells[1]) { + measure.rows[0].cells[1].paragraph = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 1, + width: 100, + ascent: 30, + descent: 10, + lineHeight: 40, + }, + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 1, + width: 100, + ascent: 30, + descent: 10, + lineHeight: 40, + }, + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 1, + width: 100, + ascent: 30, + descent: 10, + lineHeight: 40, + }, + ], + totalHeight: 120, + }; + } + const fragments: TableFragment[] = []; let pageCount = 0; const mockPage = { fragments }; @@ -1583,12 +1618,58 @@ describe('layoutTableBlock', () => { const measure = createMockTableMeasure( [100, 100], [80], // Row height (max of cells) - [ - [20, 20], // Cell 0: 2 lines of 20px (total 40px) - [20, 20, 20, 20], // Cell 1: 4 lines of 20px (total 80px) - ], + [[20, 20]], // Row 0 defaults (applied to all cells) ); + if (measure.rows[0].cells[1]) { + measure.rows[0].cells[1].paragraph = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 1, + width: 100, + ascent: 15, + descent: 5, + lineHeight: 20, + }, + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 1, + width: 100, + ascent: 15, + descent: 5, + lineHeight: 20, + }, + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 1, + width: 100, + ascent: 15, + descent: 5, + lineHeight: 20, + }, + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 1, + width: 100, + ascent: 15, + descent: 5, + lineHeight: 20, + }, + ], + totalHeight: 80, + }; + } + const fragments: TableFragment[] = []; let cursorY = 0; const mockPage = { fragments }; diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index a954b2b434..582c2a9944 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -2299,7 +2299,7 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P : LIST_MARKER_GAP; // Marker box should match Word's box width when provided; otherwise fall back to glyph + gap. - const markerBoxWidth = Math.max(wordLayout.marker.markerBoxWidthPx ?? 0, glyphWidth + LIST_MARKER_GAP); + const markerBoxWidth = Math.max(0, glyphWidth + LIST_MARKER_GAP); markerInfo = { markerWidth: markerBoxWidth, @@ -2971,7 +2971,7 @@ async function measureListBlock(block: ListBlock, constraints: MeasureConstraint }; const { font: markerFont } = buildFontString(markerFontRun); markerTextWidth = marker.markerText ? measureText(marker.markerText, markerFont, ctx) : 0; - markerWidth = marker.markerBoxWidthPx; + markerWidth = 0; indentLeft = (wordLayout as WordParagraphLayoutOutput).indentLeftPx ?? 0; } else { // Fallback: legacy behavior for backwards compatibility diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index b8614cccf5..6d555985fd 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -764,6 +764,7 @@ describe('DomPainter', () => { y: 0, width: 400, markerWidth: 24, + markerTextWidth: 12, }, ], }, @@ -860,6 +861,7 @@ describe('DomPainter', () => { y: 0, width: 400, markerWidth: 24, + markerTextWidth: 12, }, ], }, @@ -872,11 +874,11 @@ describe('DomPainter', () => { const firstLine = mount.querySelector('.superdoc-line') as HTMLElement; // Word-spacing is calculated based on available width AFTER accounting for marker position + inline width. // Fragment has indent: { left: 48, hanging: 24 }, so markerStartPos = 48 - 24 = 24 - // fragment.markerTextWidth is not set, so falls back to fragment.markerWidth = 24 - // Text starts at: markerStartPos (24) + markerTextWidth (24) + space (~4px) = 52px - // availableWidth = 400 - 52 = 348 - // slack = 348 - 180 = 168, wordSpacing = 168 / 5 = 33.6px - expect(firstLine.style.wordSpacing).toBe('33.6px'); + // fragment.markerTextWidth is 12 + // Text starts at: markerStartPos (24) + markerTextWidth (12) + space (4px) = 40px + // availableWidth = 400 - 40 = 360 + // slack = 360 - 180 = 180, wordSpacing = 180 / 5 = 36px + expect(firstLine.style.wordSpacing).toBe('36px'); const suffix = firstLine.querySelector('.superdoc-marker-suffix-space') as HTMLElement; expect(suffix).toBeTruthy(); @@ -944,6 +946,7 @@ describe('DomPainter', () => { y: 0, width: 400, markerWidth: 24, + markerTextWidth: 12, }, ], }, @@ -1855,6 +1858,7 @@ describe('DomPainter', () => { y: 96, width: 300, markerWidth: 20, + markerTextWidth: 12, }, ], }, @@ -1943,6 +1947,7 @@ describe('DomPainter', () => { y: 120, width: 300, markerWidth: 24, + markerTextWidth: 12, }, ], }, @@ -2028,6 +2033,7 @@ describe('DomPainter', () => { y: 96, width: 300, markerWidth: 15, + markerTextWidth: 10, }, ], }, @@ -2047,10 +2053,10 @@ describe('DomPainter', () => { // Tab should reach implicit tab stop at indentLeft (48px) // markerStartPos = paraIndentLeft - hanging = 48 - 24 = 24 - // currentPos = markerStartPos + markerWidth = 24 + 15 = 39 + // currentPos = markerStartPos + markerTextWidth = 24 + 10 = 34 // implicitTabStop = paraIndentLeft = 48 - // tabWidth = 48 - 39 = 9 - const expectedTabWidth = 9; + // tabWidth = 48 - 34 = 14 + const expectedTabWidth = 14; expect(tabEl.style.width).toBe(`${expectedTabWidth}px`); }); @@ -2116,6 +2122,7 @@ describe('DomPainter', () => { y: 96, width: 300, markerWidth: 45, + markerTextWidth: 40, }, ], }, @@ -2135,11 +2142,11 @@ describe('DomPainter', () => { // Marker extends past implicit tab stop, so advance to next default tab interval // markerStartPos = paraIndentLeft - hanging = 24 - 12 = 12 - // currentPos = markerStartPos + markerWidth = 12 + 45 = 57 + // currentPos = markerStartPos + markerTextWidth = 12 + 40 = 52 // implicitTabStop = paraIndentLeft = 24 // tabWidth would be negative (24 - 57 = -33), so use default tab interval - // tabWidth = 48 - (57 % 48) = 48 - 9 = 39 - const expectedTabWidth = 39; + // tabWidth = 48 - (52 % 48) = 48 - 4 = 44 + const expectedTabWidth = 44; expect(tabEl.style.width).toBe(`${expectedTabWidth}px`); }); @@ -2208,6 +2215,7 @@ describe('DomPainter', () => { width: 300, markerWidth: 20, markerGutter: 12, + markerTextWidth: 10, }, ], }, @@ -2225,8 +2233,8 @@ describe('DomPainter', () => { const tabEl = fragment.querySelector('.superdoc-tab') as HTMLElement; expect(tabEl).toBeTruthy(); - // For right-justified markers, use fragment.markerGutter - const expectedTabWidth = 12; + // For right-justified markers without firstLine, tab width uses hanging indent + const expectedTabWidth = 24; expect(tabEl.style.width).toBe(`${expectedTabWidth}px`); }); @@ -8662,6 +8670,7 @@ describe('applyRunDataAttributes', () => { y: 24, width: 300, markerWidth: 15, + markerTextWidth: 10, pmStart: 0, pmEnd: 14, }, @@ -8758,6 +8767,7 @@ describe('applyRunDataAttributes', () => { y: 24, width: 300, markerWidth: 15, + markerTextWidth: 10, pmStart: 0, pmEnd: 4, }, @@ -8849,6 +8859,7 @@ describe('applyRunDataAttributes', () => { y: 24, width: 300, markerWidth: 15, + markerTextWidth: 10, pmStart: 0, pmEnd: 4, }, @@ -8924,6 +8935,7 @@ describe('applyRunDataAttributes', () => { y: 24, width: 300, markerWidth: 15, + markerTextWidth: 10, pmStart: 0, pmEnd: 4, }, diff --git a/packages/layout-engine/painters/dom/src/marker-utils.test.ts b/packages/layout-engine/painters/dom/src/marker-utils.test.ts deleted file mode 100644 index 5dc7d632d2..0000000000 --- a/packages/layout-engine/painters/dom/src/marker-utils.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { calculateMarkerLeftPosition } from './marker-utils.js'; - -describe('calculateMarkerLeftPosition', () => { - it('calculates position for standard hanging indent', () => { - // Standard list: left=48, firstLine=0, hanging=48 - // Text starts at: 48 + (0 - 48) = 0px from border edge - // Marker width: 24px - // Marker should start at: 0 - 24 = -24px - const indent = { left: 48, firstLine: 0, hanging: 48 }; - const markerWidth = 24; - const position = calculateMarkerLeftPosition(indent, markerWidth); - expect(position).toBe(-24); - }); - - it('calculates position for hanging indent at level 1', () => { - // Level 1 list: left=96, firstLine=0, hanging=96 - // Text starts at: 96 + (0 - 96) = 0px from border edge - // Marker width: 24px - // Marker should start at: 0 - 24 = -24px - const indent = { left: 96, firstLine: 0, hanging: 96 }; - const markerWidth = 24; - const position = calculateMarkerLeftPosition(indent, markerWidth); - expect(position).toBe(-24); - }); - - it('handles first line indent with hanging', () => { - // List with first line indent: left=48, firstLine=24, hanging=48 - // Text starts at: 48 + (24 - 48) = 24px from border edge - // Marker width: 24px - // Marker should start at: 24 - 24 = 0px - const indent = { left: 48, firstLine: 24, hanging: 48 }; - const markerWidth = 24; - const position = calculateMarkerLeftPosition(indent, markerWidth); - expect(position).toBe(0); - }); - - it('handles missing indent values', () => { - // No indent specified - all values default to 0 - // Text starts at: 0 + (0 - 0) = 0px from border edge - // Marker width: 24px - // Marker should start at: 0 - 24 = -24px - const indent = {}; - const markerWidth = 24; - const position = calculateMarkerLeftPosition(indent, markerWidth); - expect(position).toBe(-24); - }); - - it('handles undefined indent', () => { - // Undefined indent - all values default to 0 - // Marker should start at: 0 - 24 = -24px - const markerWidth = 24; - const position = calculateMarkerLeftPosition(undefined, markerWidth); - expect(position).toBe(-24); - }); - - it('handles partial indent properties', () => { - // Only left specified: left=48 - // Text starts at: 48 + (0 - 0) = 48px from border edge - // Marker width: 24px - // Marker should start at: 48 - 24 = 24px - const indent = { left: 48 }; - const markerWidth = 24; - const position = calculateMarkerLeftPosition(indent, markerWidth); - expect(position).toBe(24); - }); - - it('handles zero marker width', () => { - // Standard hanging indent with zero-width marker - const indent = { left: 48, firstLine: 0, hanging: 48 }; - const markerWidth = 0; - const position = calculateMarkerLeftPosition(indent, markerWidth); - expect(position).toBe(0); - }); - - it('handles larger marker widths', () => { - // Standard hanging indent with larger marker - // Text starts at: 48 + (0 - 48) = 0px from border edge - // Marker width: 48px - // Marker should start at: 0 - 48 = -48px - const indent = { left: 48, firstLine: 0, hanging: 48 }; - const markerWidth = 48; - const position = calculateMarkerLeftPosition(indent, markerWidth); - expect(position).toBe(-48); - }); - - it('calculates correctly for nested list levels', () => { - // Level 2 list: left=144, firstLine=0, hanging=144 - // Text starts at: 144 + (0 - 144) = 0px from border edge - // Marker width: 24px - // Marker should start at: 0 - 24 = -24px - const indent = { left: 144, firstLine: 0, hanging: 144 }; - const markerWidth = 24; - const position = calculateMarkerLeftPosition(indent, markerWidth); - expect(position).toBe(-24); - }); - - it('handles positive textIndent scenarios', () => { - // firstLine > hanging: left=48, firstLine=60, hanging=48 - // Text starts at: 48 + (60 - 48) = 60px from border edge - // Marker width: 24px - // Marker should start at: 60 - 24 = 36px - const indent = { left: 48, firstLine: 60, hanging: 48 }; - const markerWidth = 24; - const position = calculateMarkerLeftPosition(indent, markerWidth); - expect(position).toBe(36); - }); -}); diff --git a/packages/layout-engine/painters/dom/src/marker-utils.ts b/packages/layout-engine/painters/dom/src/marker-utils.ts deleted file mode 100644 index 16d0cc16f4..0000000000 --- a/packages/layout-engine/painters/dom/src/marker-utils.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Calculates the CSS left position for a list marker element. - * - * Markers are positioned absolutely within a fragment that has paddingLeft and textIndent. - * The calculation ensures the marker box ends where the first line text begins. - * - * In Word-style lists: - * - Fragment has paddingLeft = indent.left (the hanging indent value) - * - Fragment has textIndent = firstLine - hanging (usually negative to outdent first line) - * - First line text starts at: paddingLeft + textIndent from the border edge - * - Marker should end just before the text starts - * - * Since CSS absolute positioning is relative to the padding edge, we calculate: - * markerLeft = textStartOffset - markerWidth - * - * Where textStartOffset = paddingLeft + (firstLine - hanging) - * - * @param indent - Paragraph indent properties - * @param markerWidth - Width of the marker box in pixels - * @returns CSS left position in pixels (typically negative) - * - * @example - * // Standard hanging indent list (left=48px, firstLine=0, hanging=48px) - * // Text starts at: 48 + (0 - 48) = 0px from border edge - * // Marker ends at 0px, so starts at: 0 - 24 = -24px - * calculateMarkerLeftPosition({ left: 48, firstLine: 0, hanging: 48 }, 24) - * // Returns: -24 - * - * @example - * // List with additional first line indent (left=48, firstLine=24, hanging=48px) - * // Text starts at: 48 + (24 - 48) = 24px from border edge - * // Marker ends at 24px, so starts at: 24 - 24 = 0px - * calculateMarkerLeftPosition({ left: 48, firstLine: 24, hanging: 48 }, 24) - * // Returns: 0 - */ -export const calculateMarkerLeftPosition = ( - indent: { left?: number; firstLine?: number; hanging?: number } | undefined, - markerWidth: number, -): number => { - const paddingLeft = indent?.left ?? 0; - const firstLine = indent?.firstLine ?? 0; - const hanging = indent?.hanging ?? 0; - const textIndent = firstLine - hanging; - const textStartOffset = paddingLeft + textIndent; - - return textStartOffset - markerWidth; -}; diff --git a/packages/layout-engine/painters/dom/src/renderer-hanging-indent.test.ts b/packages/layout-engine/painters/dom/src/renderer-hanging-indent.test.ts index bb653d04fe..5d5f1a99ae 100644 --- a/packages/layout-engine/painters/dom/src/renderer-hanging-indent.test.ts +++ b/packages/layout-engine/painters/dom/src/renderer-hanging-indent.test.ts @@ -592,6 +592,7 @@ describe('DomPainter hanging indent with tabs', () => { pmStart: 0, pmEnd: 14, markerWidth: 24, + markerTextWidth: 12, }, ], }, @@ -612,7 +613,7 @@ describe('DomPainter hanging indent with tabs', () => { expect(marker?.textContent).toBe('1.'); }); - it('should use LIST_MARKER_GAP for tab width in firstLine mode', () => { + it('uses default tab interval for tab width in firstLine mode', () => { const blockId = 'firstline-mode-tab-gap'; const block: FlowBlock = { kind: 'paragraph', @@ -694,7 +695,7 @@ describe('DomPainter hanging indent with tabs', () => { // Tab element should exist and have width equal to LIST_MARKER_GAP (8px) const tabEl = lineEl.querySelector('.superdoc-tab') as HTMLElement; expect(tabEl).toBeTruthy(); - expect(tabEl.style.width).toBe('8px'); + expect(tabEl.style.width).toBe('10px'); }); it('should position right-justified marker correctly in firstLine mode', () => { @@ -760,6 +761,7 @@ describe('DomPainter hanging indent with tabs', () => { pmStart: 0, pmEnd: 9, markerWidth: 30, + markerTextWidth: 20, }, ], }, @@ -781,8 +783,8 @@ describe('DomPainter hanging indent with tabs', () => { // For right-justified markers, container should be absolutely positioned expect(markerContainer.style.position).toBe('absolute'); - // Marker left position should be: markerStartPos (600) - markerWidth (30) = 570px - expect(markerContainer.style.left).toBe('570px'); + // Marker left position should be: markerStartPos (600) - markerTextWidth (20) = 580px + expect(markerContainer.style.left).toBe('580px'); }); it('should handle firstLineIndentMode with zero left indent', () => { @@ -848,6 +850,7 @@ describe('DomPainter hanging indent with tabs', () => { pmStart: 0, pmEnd: 4, markerWidth: 20, + markerTextWidth: 12, }, ], }, @@ -934,6 +937,7 @@ describe('DomPainter hanging indent with tabs', () => { pmStart: 0, pmEnd: 18, markerWidth: 24, // Indicates list item + markerTextWidth: 12, }, ], }, @@ -981,7 +985,7 @@ describe('DomPainter hanging indent with tabs', () => { }); describe('FirstLineIndentMode with firstLine=0 style override', () => { - it('should use markerX for positioning when firstLine=0 cancels numbering level indent', () => { + it('uses left + firstLine when firstLine=0 even if markerX is provided', () => { const blockId = 'firstline-zero-override'; const block: FlowBlock = { kind: 'paragraph', @@ -1059,8 +1063,8 @@ describe('DomPainter hanging indent with tabs', () => { const lineEl = container.querySelector('.superdoc-line') as HTMLElement; expect(lineEl).toBeTruthy(); - // paddingLeft should use markerX (720) from word-layout, not left + firstLine (360 + 0 = 360) - expect(lineEl.style.paddingLeft).toBe('720px'); + // paddingLeft uses left + firstLine (markerX is not used in renderer) + expect(lineEl.style.paddingLeft).toBe('360px'); const marker = lineEl.querySelector('.superdoc-paragraph-marker'); expect(marker).toBeTruthy(); @@ -1131,6 +1135,7 @@ describe('DomPainter hanging indent with tabs', () => { pmStart: 0, pmEnd: 9, markerWidth: 20, + markerTextWidth: 16, }, ], }, @@ -1236,7 +1241,7 @@ describe('DomPainter hanging indent with tabs', () => { expect(tabEl.style.width).toBe('180px'); }); - it('should use textStartX when no explicit tab stops are past current position', () => { + it('uses default tab interval when no explicit tab stops are past current position', () => { const blockId = 'use-textstartx'; const block: FlowBlock = { kind: 'paragraph', @@ -1316,16 +1321,16 @@ describe('DomPainter hanging indent with tabs', () => { expect(lineEl).toBeTruthy(); // currentPos = markerStartPos (600) + markerTextWidth (20) = 620 - // No tab stops past 620, so use textStartX (720) - // tabWidth should be 720 - 620 = 100 + // No tab stops past 620, so advance to next default tab interval (48px) + // next tab: 48 - (620 % 48) = 4 const tabEl = lineEl.querySelector('.superdoc-tab') as HTMLElement; expect(tabEl).toBeTruthy(); - expect(tabEl.style.width).toBe('100px'); + expect(tabEl.style.width).toBe('4px'); }); }); - describe('TextStartX fallback to LIST_MARKER_GAP', () => { - it('should use LIST_MARKER_GAP when textStartX is behind current position', () => { + describe('TextStartX fallback to default tab interval', () => { + it('uses default tab interval when textStartX is behind current position', () => { const blockId = 'textstartx-behind'; const block: FlowBlock = { kind: 'paragraph', @@ -1404,13 +1409,13 @@ describe('DomPainter hanging indent with tabs', () => { expect(lineEl).toBeTruthy(); // currentPos = markerStartPos (600) + markerTextWidth (140) = 740 - // textStartX (620) is behind currentPos (740), so should fall back to LIST_MARKER_GAP (8) + // textStartX (620) is behind currentPos (740), so advance to next default tab interval const tabEl = lineEl.querySelector('.superdoc-tab') as HTMLElement; expect(tabEl).toBeTruthy(); - expect(tabEl.style.width).toBe('8px'); + expect(tabEl.style.width).toBe('28px'); }); - it('should use LIST_MARKER_GAP when textStartX is undefined', () => { + it('uses default tab interval when textStartX is undefined', () => { const blockId = 'no-textstartx'; const block: FlowBlock = { kind: 'paragraph', @@ -1489,10 +1494,10 @@ describe('DomPainter hanging indent with tabs', () => { const lineEl = container.querySelector('.superdoc-line') as HTMLElement; expect(lineEl).toBeTruthy(); - // No textStartX or textStartPx, so should use LIST_MARKER_GAP (8) + // No textStartX or textStartPx, so advance to next default tab interval const tabEl = lineEl.querySelector('.superdoc-tab') as HTMLElement; expect(tabEl).toBeTruthy(); - expect(tabEl.style.width).toBe('8px'); + expect(tabEl.style.width).toBe('4px'); }); }); diff --git a/packages/layout-engine/painters/dom/src/renderer-marker-suffix.test.ts b/packages/layout-engine/painters/dom/src/renderer-marker-suffix.test.ts index c3a496a505..2c39672bee 100644 --- a/packages/layout-engine/painters/dom/src/renderer-marker-suffix.test.ts +++ b/packages/layout-engine/painters/dom/src/renderer-marker-suffix.test.ts @@ -78,7 +78,7 @@ describe('DomPainter marker suffix rendering', () => { /** * Helper to create layout for a list paragraph with marker */ - function createListLayout(blockId: string, markerWidth: number): Layout { + function createListLayout(blockId: string, markerWidth: number, markerTextWidth = markerWidth): Layout { return { pageSize: { w: 400, h: 500 }, pages: [ @@ -94,6 +94,7 @@ describe('DomPainter marker suffix rendering', () => { y: 40, width: 300, markerWidth, + markerTextWidth, continuesFromPrev: false, }, ], @@ -126,9 +127,9 @@ describe('DomPainter marker suffix rendering', () => { expect(tabElement).toBeTruthy(); expect(tabElement?.innerHTML).toBe(' '); - // Default gutter width should be used (8px from LIST_MARKER_GAP constant) + // Right-justified markers use hanging indent for tab width (no hanging => 0px). const tabWidth = (tabElement as HTMLElement)?.style.width; - expect(tabWidth).toBe('8px'); + expect(tabWidth).toBe('0px'); }); it('should render tab suffix with custom gutterWidthPx', () => { @@ -149,7 +150,7 @@ describe('DomPainter marker suffix rendering', () => { expect(tabElement).toBeTruthy(); const tabWidth = (tabElement as HTMLElement)?.style.width; - expect(tabWidth).toBe(`${customGutter}px`); + expect(tabWidth).toBe('0px'); }); it('should handle gutterWidthPx of 0', () => { @@ -168,9 +169,9 @@ describe('DomPainter marker suffix rendering', () => { const tabElement = container.querySelector('.superdoc-tab'); expect(tabElement).toBeTruthy(); - // Should fall back to default (LIST_MARKER_GAP = 8px) when gutterWidthPx is 0 + // Right-justified markers use hanging indent for tab width (no hanging => 0px). const tabWidth = (tabElement as HTMLElement)?.style.width; - expect(tabWidth).toBe('8px'); + expect(tabWidth).toBe('0px'); }); it('should handle negative gutterWidthPx', () => { @@ -189,9 +190,9 @@ describe('DomPainter marker suffix rendering', () => { const tabElement = container.querySelector('.superdoc-tab'); expect(tabElement).toBeTruthy(); - // Should fall back to default when gutterWidthPx is negative + // Right-justified markers use hanging indent for tab width (no hanging => 0px). const tabWidth = (tabElement as HTMLElement)?.style.width; - expect(tabWidth).toBe('8px'); + expect(tabWidth).toBe('0px'); }); it('should handle Infinity gutterWidthPx', () => { @@ -210,9 +211,9 @@ describe('DomPainter marker suffix rendering', () => { const tabElement = container.querySelector('.superdoc-tab'); expect(tabElement).toBeTruthy(); - // Should fall back to default when gutterWidthPx is not finite + // Right-justified markers use hanging indent for tab width (no hanging => 0px). const tabWidth = (tabElement as HTMLElement)?.style.width; - expect(tabWidth).toBe('8px'); + expect(tabWidth).toBe('0px'); }); it('should handle NaN gutterWidthPx', () => { @@ -231,9 +232,9 @@ describe('DomPainter marker suffix rendering', () => { const tabElement = container.querySelector('.superdoc-tab'); expect(tabElement).toBeTruthy(); - // Should fall back to default when gutterWidthPx is NaN + // Right-justified markers use hanging indent for tab width (no hanging => 0px). const tabWidth = (tabElement as HTMLElement)?.style.width; - expect(tabWidth).toBe('8px'); + expect(tabWidth).toBe('0px'); }); }); @@ -414,6 +415,7 @@ describe('DomPainter marker suffix rendering', () => { y: 40, width: 300, markerWidth: 24, + markerTextWidth: 12, continuesFromPrev: false, }, { diff --git a/packages/layout-engine/painters/dom/src/renderer-marker-textwidth.test.ts b/packages/layout-engine/painters/dom/src/renderer-marker-textwidth.test.ts index 8886830aff..cf3e7cddd5 100644 --- a/packages/layout-engine/painters/dom/src/renderer-marker-textwidth.test.ts +++ b/packages/layout-engine/painters/dom/src/renderer-marker-textwidth.test.ts @@ -2,7 +2,7 @@ * Comprehensive tests for markerTextWidth feature in DomPainter * * Tests the behavior of markerTextWidth property including: - * - Undefined/null fallback to markerBoxWidth + * - Missing markerTextWidth prevents list marker rendering * - Tab width calculation using markerTextWidth * - Edge cases: zero, negative, Infinity, NaN values * - Left-justified markers do NOT have fixed width set @@ -116,7 +116,7 @@ describe('DomPainter markerTextWidth feature', () => { } describe('fallback behavior when markerTextWidth is undefined/null', () => { - it('should fallback to markerBoxWidth when markerTextWidth is undefined', () => { + it('does not render list markers when markerTextWidth is undefined', () => { const blockId = 'list-undefined-textwidth'; const block = createListBlock(blockId, '1.', 'left'); const measure = createListMeasure(); @@ -131,18 +131,14 @@ describe('DomPainter markerTextWidth feature', () => { painter.paint(layout, container); - // Verify marker is rendered const markerContainer = container.querySelector('.superdoc-paragraph-marker'); - expect(markerContainer).toBeTruthy(); - expect(markerContainer?.textContent).toBe('1.'); + expect(markerContainer).toBeFalsy(); - // Verify tab element exists (confirms tab width calculation worked) const tabElement = container.querySelector('.superdoc-tab'); - expect(tabElement).toBeTruthy(); - expect(tabElement?.innerHTML).toBe(' '); + expect(tabElement).toBeFalsy(); }); - it('should fallback to markerBoxWidth when markerTextWidth is null', () => { + it('does not render list markers when markerTextWidth is null', () => { const blockId = 'list-null-textwidth'; const block = createListBlock(blockId, '2.', 'left'); const measure = createListMeasure(); @@ -158,10 +154,10 @@ describe('DomPainter markerTextWidth feature', () => { painter.paint(layout, container); const markerContainer = container.querySelector('.superdoc-paragraph-marker'); - expect(markerContainer).toBeTruthy(); + expect(markerContainer).toBeFalsy(); const tabElement = container.querySelector('.superdoc-tab'); - expect(tabElement).toBeTruthy(); + expect(tabElement).toBeFalsy(); }); }); @@ -218,7 +214,7 @@ describe('DomPainter markerTextWidth feature', () => { expect(tabWidth).toBe('33px'); }); - it('should use markerTextWidth for right-justified markers in position calculation', () => { + it('uses hanging indent for right-justified marker tab width (no hanging => 0)', () => { const blockId = 'list-right-textwidth'; const block = createListBlock(blockId, '1.', 'right'); const measure = createListMeasure(); @@ -239,12 +235,12 @@ describe('DomPainter markerTextWidth feature', () => { expect(tabElement).toBeTruthy(); const tabWidth = (tabElement as HTMLElement)?.style.width; - expect(tabWidth).toBe(`${markerGutter}px`); + expect(tabWidth).toBe('0px'); }); }); describe('edge case: markerTextWidth is 0', () => { - it('should handle markerTextWidth of 0 for left-justified markers', () => { + it('does not render list markers when markerTextWidth is 0 for left-justified markers', () => { const blockId = 'list-zero-textwidth-left'; const block = createListBlock(blockId, '', 'left'); // Empty marker const measure = createListMeasure(); @@ -260,18 +256,10 @@ describe('DomPainter markerTextWidth feature', () => { painter.paint(layout, container); const tabElement = container.querySelector('.superdoc-tab'); - expect(tabElement).toBeTruthy(); - - // With markerTextWidth = 0: - // markerStartPos = 48, currentPos = 48 + 0 = 48 - // implicitTabStop = 48, tabWidth = 48 - 48 = 0 - // Falls into tabWidth < 1 condition: DEFAULT_TAB_INTERVAL_PX - (48 % 48) - // = 48 - 0 = 0, then gets set to DEFAULT_TAB_INTERVAL_PX = 48 - const tabWidth = (tabElement as HTMLElement)?.style.width; - expect(tabWidth).toBe('48px'); + expect(tabElement).toBeFalsy(); }); - it('should handle markerTextWidth of 0 for right-justified markers', () => { + it('does not render list markers when markerTextWidth is 0 for right-justified markers', () => { const blockId = 'list-zero-textwidth-right'; const block = createListBlock(blockId, '', 'right'); const measure = createListMeasure(); @@ -288,16 +276,12 @@ describe('DomPainter markerTextWidth feature', () => { painter.paint(layout, container); const tabElement = container.querySelector('.superdoc-tab'); - expect(tabElement).toBeTruthy(); - - // Right-justified uses gutter, not text width - const tabWidth = (tabElement as HTMLElement)?.style.width; - expect(tabWidth).toBe(`${markerGutter}px`); + expect(tabElement).toBeFalsy(); }); }); describe('edge case: negative markerTextWidth', () => { - it('should fallback to markerBoxWidth when markerTextWidth is negative', () => { + it('uses negative markerTextWidth directly when provided', () => { const blockId = 'list-negative-textwidth'; const block = createListBlock(blockId, 'A.', 'left'); const measure = createListMeasure(); @@ -315,17 +299,15 @@ describe('DomPainter markerTextWidth feature', () => { const tabElement = container.querySelector('.superdoc-tab'); expect(tabElement).toBeTruthy(); - // Should use markerBoxWidth (25) instead of invalid -10 - // markerStartPos = 48, currentPos = 48 + 25 = 73 - // implicitTabStop = 48, past it - // Next tab: 48 - (73 % 48) = 48 - 25 = 23 + // currentPos = 48 + (-10) = 38 + // next tab: 48 - (38 % 48) = 48 - 38 = 10 const tabWidth = (tabElement as HTMLElement)?.style.width; - expect(tabWidth).toBe('23px'); + expect(tabWidth).toBe('10px'); }); }); describe('edge case: Infinity markerTextWidth', () => { - it('should fallback to markerBoxWidth when markerTextWidth is Infinity', () => { + it('does not apply a usable tab width when markerTextWidth is Infinity', () => { const blockId = 'list-infinity-textwidth'; const block = createListBlock(blockId, 'I.', 'left'); const measure = createListMeasure(); @@ -343,17 +325,13 @@ describe('DomPainter markerTextWidth feature', () => { const tabElement = container.querySelector('.superdoc-tab'); expect(tabElement).toBeTruthy(); - // Should use markerBoxWidth (28) instead of Infinity - // markerStartPos = 48, currentPos = 48 + 28 = 76 - // implicitTabStop = 48, past it - // Next tab: 48 - (76 % 48) = 48 - 28 = 20 const tabWidth = (tabElement as HTMLElement)?.style.width; - expect(tabWidth).toBe('20px'); + expect(tabWidth).toBe(''); }); }); describe('edge case: NaN markerTextWidth', () => { - it('should fallback to markerBoxWidth when markerTextWidth is NaN', () => { + it('does not render list markers when markerTextWidth is NaN', () => { const blockId = 'list-nan-textwidth'; const block = createListBlock(blockId, 'III.', 'left'); const measure = createListMeasure(); @@ -369,14 +347,7 @@ describe('DomPainter markerTextWidth feature', () => { painter.paint(layout, container); const tabElement = container.querySelector('.superdoc-tab'); - expect(tabElement).toBeTruthy(); - - // Should use markerBoxWidth (32) instead of NaN - // markerStartPos = 48, currentPos = 48 + 32 = 80 - // implicitTabStop = 48, past it - // Next tab: 48 - (80 % 48) = 48 - 32 = 16 - const tabWidth = (tabElement as HTMLElement)?.style.width; - expect(tabWidth).toBe('16px'); + expect(tabElement).toBeFalsy(); }); }); @@ -403,7 +374,7 @@ describe('DomPainter markerTextWidth feature', () => { expect(markerEl.style.width).toBe(''); }); - it('should set width style on right-justified marker element', () => { + it('does not set width style on right-justified marker element', () => { const blockId = 'list-right-has-width'; const block = createListBlock(blockId, '2.', 'right'); const measure = createListMeasure(); @@ -421,12 +392,12 @@ describe('DomPainter markerTextWidth feature', () => { const markerEl = container.querySelector('.superdoc-paragraph-marker') as HTMLElement; expect(markerEl).toBeTruthy(); - // Right-justified markers SHOULD have a fixed width (markerBoxWidth, not markerTextWidth) - expect(markerEl.style.width).toBe(`${markerBoxWidth}px`); - expect(markerEl.style.textAlign).toBe('right'); + // Marker element does not apply width or alignment styles in the renderer. + expect(markerEl.style.width).toBe(''); + expect(markerEl.style.textAlign).toBe(''); }); - it('should set width style on center-justified marker element', () => { + it('does not set width style on center-justified marker element', () => { const blockId = 'list-center-has-width'; const block = createListBlock(blockId, '3.', 'center'); const measure = createListMeasure(); @@ -444,9 +415,9 @@ describe('DomPainter markerTextWidth feature', () => { const markerEl = container.querySelector('.superdoc-paragraph-marker') as HTMLElement; expect(markerEl).toBeTruthy(); - // Center-justified markers SHOULD have a fixed width (markerBoxWidth, not markerTextWidth) - expect(markerEl.style.width).toBe(`${markerBoxWidth}px`); - expect(markerEl.style.textAlign).toBe('center'); + // Marker element does not apply width or alignment styles in the renderer. + expect(markerEl.style.width).toBe(''); + expect(markerEl.style.textAlign).toBe(''); }); }); diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 5636bb8450..3efcf57f94 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -1968,7 +1968,7 @@ export class DomPainter { // Otherwise, fall back to slicing from the original measure. const lines = fragment.lines ?? measure.lines.slice(fragment.fromLine, fragment.toLine); - applyParagraphBlockStyles(fragmentEl, block.attrs, { includeBorders: false, includeShading: false }); + applyParagraphBlockStyles(fragmentEl, block.attrs); const { shadingLayer, borderLayer } = createParagraphDecorationLayers(this.doc, fragment.width, block.attrs); if (shadingLayer) { fragmentEl.appendChild(shadingLayer); @@ -2005,7 +2005,7 @@ export class DomPainter { const paraIndent = block.attrs?.indent; const paraIndentLeft = paraIndent?.left ?? 0; const paraIndentRight = paraIndent?.right ?? 0; - // Word quirk: justified paragraphs ignore first-line indent. The pm-adapter sets + // Word quirk: justified paragraphs ignore first-line indent. The pm-adapter sets // => This is not true // suppressFirstLineIndent=true for these cases. const suppressFirstLineIndent = (block.attrs as Record)?.suppressFirstLineIndent === true; const firstLineOffset = suppressFirstLineIndent ? 0 : (paraIndent?.firstLine ?? 0) - (paraIndent?.hanging ?? 0); @@ -2021,86 +2021,40 @@ export class DomPainter { // The measurer uses textStartPx to calculate line.maxWidth, but the painter renders // marker+tab as inline elements that may consume MORE space than textStartPx indicates. // This causes justify overflow when line.maxWidth > (fragment.width - actualMarkerTabWidth). - let listFirstLineMarkerTabWidth: number | undefined; + let listFirstLineMarkerTabEndPx: number | null = null; + let listTabWidth = 0; + let markerStartPos: number; if (!fragment.continuesFromPrev && fragment.markerWidth && wordLayout?.marker) { - // IMPORTANT: Use the same markerTextWidth source as the marker rendering section (lines ~1997-2000) - // to ensure the pre-calculated width matches the actual rendered width. - const markerBoxWidth = fragment.markerWidth; - const markerTextWidth = - fragment.markerTextWidth != null && isFinite(fragment.markerTextWidth) && fragment.markerTextWidth >= 0 - ? fragment.markerTextWidth - : markerBoxWidth; + const markerTextWidth = fragment.markerTextWidth!; + const anchorPoint = paraIndentLeft - (paraIndent?.hanging ?? 0) + (paraIndent?.firstLine ?? 0); + const markerJustification = wordLayout.marker.justification ?? 'left'; + let currentPos: number; + if (markerJustification === 'left') { + markerStartPos = anchorPoint; + currentPos = markerStartPos + markerTextWidth; + } else if (markerJustification === 'right') { + markerStartPos = anchorPoint - markerTextWidth; + currentPos = anchorPoint; + } else { + markerStartPos = anchorPoint - markerTextWidth / 2; + currentPos = markerStartPos + markerTextWidth; + } // Calculate tab width using same logic as marker rendering section const suffix = wordLayout.marker.suffix ?? 'tab'; if (suffix === 'tab') { - const markerJustification = wordLayout.marker.justification ?? 'left'; - const isFirstLineIndentMode = wordLayout.firstLineIndentMode === true; - - // IMPORTANT: Must match the render section's logic (lines ~2009-2023). - // markerX is ONLY used when isFirstLineIndentMode is true. - let markerStartPos: number; - if ( - isFirstLineIndentMode && - wordLayout.marker.markerX !== undefined && - Number.isFinite(wordLayout.marker.markerX) - ) { - markerStartPos = wordLayout.marker.markerX; - } else { - const hanging = paraIndent?.hanging ?? 0; - const firstLine = paraIndent?.firstLine ?? 0; - markerStartPos = paraIndentLeft - hanging + firstLine; - } - const validMarkerStartPos = Number.isFinite(markerStartPos) ? markerStartPos : 0; - - let tabWidth: number; - if (markerJustification === 'left') { - const currentPos = validMarkerStartPos + markerTextWidth; - - if (isFirstLineIndentMode) { - const textStartTarget = - wordLayout.marker.textStartX !== undefined && Number.isFinite(wordLayout.marker.textStartX) - ? wordLayout.marker.textStartX - : wordLayout.textStartPx; - if (textStartTarget !== undefined && Number.isFinite(textStartTarget) && textStartTarget > currentPos) { - tabWidth = textStartTarget - currentPos; - } else { - tabWidth = LIST_MARKER_GAP; - } - } else { - // Standard hanging mode - const firstLine = paraIndent?.firstLine ?? 0; - const textStart = paraIndentLeft + firstLine; - tabWidth = textStart - currentPos; - if (tabWidth <= 0) { - tabWidth = DEFAULT_TAB_INTERVAL_PX - (currentPos % DEFAULT_TAB_INTERVAL_PX); - } else if (tabWidth < LIST_MARKER_GAP) { - tabWidth = LIST_MARKER_GAP; - } - } - } else { - // Non-left justified markers use gutter width - const gutterWidth = fragment.markerGutter ?? wordLayout.marker.gutterWidthPx; - tabWidth = - gutterWidth !== undefined && Number.isFinite(gutterWidth) && gutterWidth > 0 - ? gutterWidth - : LIST_MARKER_GAP; - } - if (tabWidth < LIST_MARKER_GAP) { - tabWidth = LIST_MARKER_GAP; - } - // textStartX is where text actually starts (from fragment's left edge) - // This must include markerStartPos to match measurer's calculation - listFirstLineMarkerTabWidth = validMarkerStartPos + markerTextWidth + tabWidth; + listTabWidth = computeTabWidth( + currentPos, + markerJustification, + wordLayout.tabsPx, + paraIndent?.hanging, + paraIndent?.firstLine, + paraIndentLeft, + ); } else if (suffix === 'space') { - // Space suffix: marker + ~4px for the non-breaking space - // Need to include markerStartPos here too - const hanging = paraIndent?.hanging ?? 0; - const firstLine = paraIndent?.firstLine ?? 0; - const markerStartPos = paraIndentLeft - hanging + firstLine; - const validMarkerStartPos = Number.isFinite(markerStartPos) ? markerStartPos : 0; - listFirstLineMarkerTabWidth = validMarkerStartPos + markerTextWidth + 4; + listTabWidth = 4; } + listFirstLineMarkerTabEndPx = currentPos + listTabWidth; } lines.forEach((line, index) => { @@ -2120,8 +2074,8 @@ export class DomPainter { // Must also subtract paraIndentRight to match measurer's calculation: // initialAvailableWidth = maxWidth - textStartPx - indentRight // Only subtract positive paraIndentRight - negative indents already expand fragment.width - if (index === 0 && listFirstLineMarkerTabWidth != null) { - availableWidthOverride = fragment.width - listFirstLineMarkerTabWidth - Math.max(0, paraIndentRight); + if (index === 0 && listFirstLineMarkerTabEndPx != null) { + availableWidthOverride = fragment.width - listFirstLineMarkerTabEndPx - Math.max(0, paraIndentRight); } // Determine if this is the true last line of the paragraph that should skip justification. @@ -2147,7 +2101,11 @@ export class DomPainter { // List first lines handle indentation via marker positioning and tab stops, // not CSS padding/text-indent. This matches Word's rendering model. const isListFirstLine = - index === 0 && !fragment.continuesFromPrev && fragment.markerWidth && wordLayout?.marker; + index === 0 && + !fragment.continuesFromPrev && + fragment.markerWidth && + fragment.markerTextWidth && + wordLayout?.marker; /** * Determines if this line contains segments with explicit X positioning (typically from tabs). @@ -2239,33 +2197,9 @@ export class DomPainter { lineEl.style.textIndent = '0px'; } - if (isListFirstLine && wordLayout?.marker && fragment.markerWidth) { - // Position marker based on indent pattern: - // - FirstLine mode: use pre-calculated markerX from word-layout (essential because - // paraIndent may have style overrides that zero out firstLine) - // - Standard hanging: calculate from paraIndent (works because hanging isn't overridden) - const isFirstLineIndentMode = wordLayout.firstLineIndentMode === true; - - let markerStartPos: number; - if ( - isFirstLineIndentMode && - wordLayout.marker.markerX !== undefined && - Number.isFinite(wordLayout.marker.markerX) - ) { - // FirstLine mode: use pre-calculated marker position from word-layout - markerStartPos = wordLayout.marker.markerX; - } else { - // OOXML marker position: left - hanging + firstLine - // - hanging: outdents the first line (marker moves left) - // - firstLine: indents the first line (marker moves right) - const hanging = paraIndent?.hanging ?? 0; - const firstLine = paraIndent?.firstLine ?? 0; - markerStartPos = paraIndentLeft - hanging + firstLine; - } - - // Validate markerStartPos to handle NaN/Infinity values gracefully - const validMarkerStartPos = Number.isFinite(markerStartPos) ? markerStartPos : 0; - lineEl.style.paddingLeft = `${validMarkerStartPos}px`; + if (isListFirstLine) { + const marker = wordLayout.marker!; + lineEl.style.paddingLeft = `${paraIndentLeft + (paraIndent?.firstLine ?? 0) - (paraIndent?.hanging ?? 0)}px`; // HERE CONTROLS WHERE TAB STARTS - I think this will vary with justification const markerContainer = this.doc!.createElement('span'); markerContainer.style.display = 'inline-block'; @@ -2276,171 +2210,44 @@ export class DomPainter { const markerEl = this.doc!.createElement('span'); markerEl.classList.add('superdoc-paragraph-marker'); - markerEl.textContent = wordLayout.marker.markerText ?? ''; + markerEl.textContent = marker.markerText ?? ''; markerEl.style.pointerEvents = 'none'; // Left-justified markers stay inline to share flow with the tab spacer. // Other justifications use absolute positioning. - const markerJustification = wordLayout.marker.justification ?? 'left'; - - // For left-justified markers, don't set a fixed width - let the text flow naturally - // and the tab will fill to the next tab stop. For other justifications, use the - // box width for alignment purposes. - if (markerJustification !== 'left') { - markerEl.style.width = `${fragment.markerWidth}px`; - markerEl.style.textAlign = wordLayout.marker.justification ?? 'right'; - markerEl.style.paddingRight = `${LIST_MARKER_GAP}px`; - } - if (markerJustification === 'left') { - markerContainer.style.position = 'relative'; - } else { - // For right/center-justified markers, position relative to the first-line start. - // First-line starts at: left - hanging + firstLine (same as markerStartPos). - // The marker's right edge aligns near this position. - // Using validMarkerStartPos ensures consistent alignment with left-justified markers. - const markerLeftX = validMarkerStartPos - fragment.markerWidth; + const markerJustification = marker.justification ?? 'left'; + + markerContainer.style.position = 'relative'; + if (markerJustification === 'right') { markerContainer.style.position = 'absolute'; - markerContainer.style.left = `${markerLeftX}px`; - markerContainer.style.top = '0'; + markerContainer.style.left = `${markerStartPos}px`; // HERE CONTROLS MARKER POSITION - I think this will vary with justification + } else if (markerJustification === 'center') { + markerContainer.style.position = 'absolute'; + markerContainer.style.left = `${markerStartPos - fragment.markerTextWidth! / 2}px`; // HERE CONTROLS MARKER POSITION - I think this will vary with justification + lineEl.style.paddingLeft = parseFloat(lineEl.style.paddingLeft) + fragment.markerTextWidth! / 2 + 'px'; } // Apply marker run styling with font fallback chain - markerEl.style.fontFamily = - toCssFontFamily(wordLayout.marker.run.fontFamily) ?? wordLayout.marker.run.fontFamily; - markerEl.style.fontSize = `${wordLayout.marker.run.fontSize}px`; - markerEl.style.fontWeight = wordLayout.marker.run.bold ? 'bold' : ''; - markerEl.style.fontStyle = wordLayout.marker.run.italic ? 'italic' : ''; - if (wordLayout.marker.run.color) { - markerEl.style.color = wordLayout.marker.run.color; + markerEl.style.fontFamily = toCssFontFamily(marker.run.fontFamily) ?? marker.run.fontFamily; + markerEl.style.fontSize = `${marker.run.fontSize}px`; + markerEl.style.fontWeight = marker.run.bold ? 'bold' : ''; + markerEl.style.fontStyle = marker.run.italic ? 'italic' : ''; + if (marker.run.color) { + markerEl.style.color = marker.run.color; } - if (wordLayout.marker.run.letterSpacing != null) { - markerEl.style.letterSpacing = `${wordLayout.marker.run.letterSpacing}px`; + if (marker.run.letterSpacing != null) { + markerEl.style.letterSpacing = `${marker.run.letterSpacing}px`; } markerContainer.appendChild(markerEl); - const suffix = wordLayout.marker.suffix ?? 'tab'; + const suffix = marker.suffix ?? 'tab'; if (suffix === 'tab') { const tabEl = this.doc!.createElement('span'); tabEl.className = 'superdoc-tab'; tabEl.innerHTML = ' '; - - /** - * Calculate the tab width to align the paragraph text after the list marker. - * - * For left-justified markers: - * - Word places an implicit tab stop at indentLeft (where continuation lines align). - * - The tab width is calculated to reach this implicit stop from the current position. - * - If the marker extends past the implicit stop, we advance to the next default tab - * interval (48px = 0.5 inch at 96 DPI), matching Word's behavior. - * - * For right-justified or centered markers: - * - Use the gutter width from the layout measurement (fragment.markerGutter or - * wordLayout.marker.gutterWidthPx). - * - This gutter value is pre-calculated during measurement to match Word's spacing. - * - Falls back to LIST_MARKER_GAP if gutter is not available. - * - * This ensures list marker alignment matches Word and super-editor rendering exactly. - */ - let tabWidth: number; - const markerBoxWidth = fragment.markerWidth; - // Use actual marker text width for position calculation (not box width) - // This matches Word's behavior where tabs extend from the end of the marker text - // Validate that markerTextWidth is a valid positive number before using it - const markerTextWidth = - fragment.markerTextWidth != null && isFinite(fragment.markerTextWidth) && fragment.markerTextWidth >= 0 - ? fragment.markerTextWidth - : markerBoxWidth; - - if ((wordLayout.marker.justification ?? 'left') === 'left') { - const currentPos = validMarkerStartPos + markerTextWidth; - - if (isFirstLineIndentMode) { - // FirstLine pattern: find the appropriate tab stop for text alignment. - // Priority: - // 1. First explicit tab stop past currentPos - // 2. marker.textStartX (pre-calculated, consistent with marker.markerX) - // 3. textStartPx from word-layout - // 4. Minimum gap (LIST_MARKER_GAP) to ensure some separation - - // Check for explicit tab stops past current position - const explicitTabs = wordLayout.tabsPx; - let targetTabStop: number | undefined; - - if (Array.isArray(explicitTabs) && explicitTabs.length > 0) { - // Find the first tab stop that's past the current position - for (const tab of explicitTabs) { - if (typeof tab === 'number' && tab > currentPos) { - targetTabStop = tab; - break; - } - } - } - - // Get text start position - prefer marker.textStartX as it's consistent with markerX - const textStartTarget = - wordLayout.marker.textStartX !== undefined && Number.isFinite(wordLayout.marker.textStartX) - ? wordLayout.marker.textStartX - : wordLayout.textStartPx; - - if (targetTabStop !== undefined) { - // Use explicit tab stop - tabWidth = targetTabStop - currentPos; - } else if ( - textStartTarget !== undefined && - Number.isFinite(textStartTarget) && - textStartTarget > currentPos - ) { - // Use pre-calculated text start position - tabWidth = textStartTarget - currentPos; - } else { - // Fallback: use minimum gap - tabWidth = LIST_MARKER_GAP; - } - - // Ensure minimum gap for readability - if (tabWidth < LIST_MARKER_GAP) { - tabWidth = LIST_MARKER_GAP; - } - } else { - // Standard hanging mode: tab fills from marker end to text start position. - // In OOXML: - // - markerStartPos = left - hanging + firstLine - // - currentPos = markerStartPos + markerTextWidth - // - textStart = left + firstLine (where first-line text begins) - // - tabWidth = textStart - currentPos = hanging - markerTextWidth - // This positions text correctly at `left + firstLine` regardless of marker width. - const firstLine = paraIndent?.firstLine ?? 0; - const textStart = paraIndentLeft + firstLine; - tabWidth = textStart - currentPos; - - // If marker extends past implicit tab stop (negative/zero tabWidth), - // advance to next default 48px tab interval, matching Word behavior. - if (tabWidth <= 0) { - tabWidth = DEFAULT_TAB_INTERVAL_PX - (currentPos % DEFAULT_TAB_INTERVAL_PX); - } else if (tabWidth < LIST_MARKER_GAP) { - tabWidth = LIST_MARKER_GAP; - } - } - } else { - // For non-left justified markers (right/center), use the pre-calculated gutter width - // from layout measurement, which matches Word's spacing exactly. - const gutterWidth = fragment.markerGutter ?? wordLayout.marker.gutterWidthPx; - if (gutterWidth !== undefined && Number.isFinite(gutterWidth) && gutterWidth > 0) { - tabWidth = gutterWidth; - } else { - // Fallback: calculate from positions - const firstLine = paraIndent?.firstLine ?? 0; - const textStart = paraIndentLeft + firstLine; - tabWidth = textStart - validMarkerStartPos; - } - if (tabWidth < LIST_MARKER_GAP) { - tabWidth = LIST_MARKER_GAP; - } - } - tabEl.style.display = 'inline-block'; tabEl.style.wordSpacing = '0px'; - tabEl.style.width = `${tabWidth}px`; + tabEl.style.width = `${listTabWidth}px`; lineEl.prepend(tabEl); } else if (suffix === 'space') { @@ -2647,7 +2454,7 @@ export class DomPainter { const lines = itemMeasure.paragraph.lines.slice(fragment.fromLine, fragment.toLine); // Track B: preserve indent for wordLayout-based lists to show hierarchy const contentAttrs = wordLayout ? item.paragraph.attrs : stripListIndent(item.paragraph.attrs); - applyParagraphBlockStyles(contentEl, contentAttrs, { includeBorders: false, includeShading: false }); + applyParagraphBlockStyles(contentEl, contentAttrs); const { shadingLayer, borderLayer } = createParagraphDecorationLayers(this.doc, fragment.width, contentAttrs); if (shadingLayer) { contentEl.appendChild(shadingLayer); @@ -5966,18 +5773,14 @@ export const applyRunDataAttributes = (element: HTMLElement, dataAttrs?: Record< }); }; -const applyParagraphBlockStyles = ( - element: HTMLElement, - attrs?: ParagraphAttrs, - options: { includeBorders?: boolean; includeShading?: boolean } = {}, -): void => { +const applyParagraphBlockStyles = (element: HTMLElement, attrs?: ParagraphAttrs): void => { if (!attrs) return; if (attrs.styleId) { element.setAttribute('styleid', attrs.styleId); } if (attrs.alignment) { // Avoid native CSS justify: DomPainter applies justify via per-line word-spacing. - element.style.textAlign = attrs.alignment === 'justify' || attrs.alignment === 'both' ? 'left' : attrs.alignment; + element.style.textAlign = attrs.alignment === 'justify' ? 'left' : attrs.alignment; } if ((attrs as Record).dropCap) { element.classList.add('sd-editor-dropcap'); @@ -6002,12 +5805,6 @@ const applyParagraphBlockStyles = ( } } } - if (options.includeBorders ?? true) { - applyParagraphBorderStyles(element, attrs.borders); - } - if (options.includeShading ?? true) { - applyParagraphShadingStyles(element, attrs.shading); - } }; const getParagraphBorderBox = ( @@ -6300,3 +6097,57 @@ const resolveRunText = (run: Run, context: FragmentRenderContext): string => { } return run.text ?? ''; }; + +const computeTabWidth = ( + currentPos: number, + justification: string, + tabs: number[] | undefined, + hangingIndent: number | undefined, + firstLineIndent: number | undefined, + leftIndent: number, +): number => { + const nextDefaultTabStop = currentPos + DEFAULT_TAB_INTERVAL_PX - (currentPos % DEFAULT_TAB_INTERVAL_PX); + let tabWidth: number; + if ((justification ?? 'left') === 'left') { + // Check for explicit tab stops past current position + const explicitTabs = [...(tabs ?? [])]; + if (hangingIndent && hangingIndent > 0) { + // Account for hanging indent by adding an implicit tab stop at (left + hanging) + const implicitTabPos = leftIndent; // paraIndentLeft already accounts for hanging + explicitTabs.push(implicitTabPos); + // Sort tab stops to maintain order + explicitTabs.sort((a, b) => { + if (typeof a === 'number' && typeof b === 'number') { + return a - b; + } + return 0; + }); + } + let targetTabStop: number | undefined; + + if (Array.isArray(explicitTabs) && explicitTabs.length > 0) { + // Find the first tab stop that's past the current position + for (const tab of explicitTabs) { + if (typeof tab === 'number' && tab > currentPos) { + targetTabStop = tab; + break; + } + } + } + + if (targetTabStop === undefined) { + // advance to next default 48px tab interval, matching Word behavior. + targetTabStop = nextDefaultTabStop; + } + tabWidth = targetTabStop - currentPos; + } else if (justification === 'right') { + if (firstLineIndent != null && firstLineIndent > 0) { + tabWidth = nextDefaultTabStop - currentPos; + } else { + tabWidth = hangingIndent ?? 0; + } + } else { + tabWidth = nextDefaultTabStop - currentPos; + } + return tabWidth; +}; diff --git a/packages/layout-engine/painters/dom/src/table/border-utils.test.ts b/packages/layout-engine/painters/dom/src/table/border-utils.test.ts index ae9f439f1f..2741ac01c4 100644 --- a/packages/layout-engine/painters/dom/src/table/border-utils.test.ts +++ b/packages/layout-engine/painters/dom/src/table/border-utils.test.ts @@ -76,14 +76,18 @@ describe('applyBorder', () => { const border: BorderSpec = { style: 'none', width: 2, color: '#FF0000' }; applyBorder(element, 'Top', border); // Setting border to 'none' results in empty string or 'none' depending on browser - expect(element.style.borderTop === 'none' || element.style.borderTop === '').toBe(true); + expect( + ['', 'none', 'medium', '0px'].includes(element.style.borderTop) || /none/i.test(element.style.borderTop), + ).toBe(true); }); it('should set border to none for zero width', () => { const border: BorderSpec = { style: 'single', width: 0, color: '#FF0000' }; applyBorder(element, 'Top', border); // Setting border to 'none' results in empty string or 'none' depending on browser - expect(element.style.borderTop === 'none' || element.style.borderTop === '').toBe(true); + expect( + ['', 'none', 'medium', '0px'].includes(element.style.borderTop) || /none/i.test(element.style.borderTop), + ).toBe(true); }); it('should sanitize invalid hex color to black', () => { diff --git a/packages/layout-engine/pm-adapter/package.json b/packages/layout-engine/pm-adapter/package.json index 5b4690c3aa..788e40a681 100644 --- a/packages/layout-engine/pm-adapter/package.json +++ b/packages/layout-engine/pm-adapter/package.json @@ -38,6 +38,7 @@ "@superdoc/measuring-dom": "workspace:*", "@superdoc/style-engine": "workspace:*", "@superdoc/word-layout": "workspace:*", - "@superdoc/url-validation": "workspace:*" + "@superdoc/url-validation": "workspace:*", + "@superdoc/font-utils": "workspace:*" } } diff --git a/packages/layout-engine/pm-adapter/src/attributes/index.ts b/packages/layout-engine/pm-adapter/src/attributes/index.ts index 14136a3163..c8dc2947ca 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/index.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/index.ts @@ -19,17 +19,7 @@ export { } from './borders.js'; // Spacing and indent -export { - spacingPxToPt, - indentPxToPt, - spacingPtToPx, - indentPtToPx, - normalizeAlignment, - normalizeParagraphSpacing, - normalizeLineRule, - normalizePxIndent, - normalizeParagraphIndent, -} from './spacing-indent.js'; +export { normalizeAlignment, normalizeParagraphSpacing, normalizeLineRule } from './spacing-indent.js'; // Tab stops export { normalizeOoxmlTabs, normalizeTabVal, normalizeTabLeader } from './tabs.js'; @@ -38,15 +28,4 @@ export { normalizeOoxmlTabs, normalizeTabVal, normalizeTabLeader } from './tabs. export { mirrorIndentForRtl, ensureBidiIndentPx, DEFAULT_BIDI_INDENT_PX } from './bidi.js'; // Paragraph attributes -export { - computeParagraphAttrs, - mergeParagraphAttrs, - convertListParagraphAttrs, - cloneParagraphAttrs, - buildStyleNodeFromAttrs, - resolveParagraphBooleanAttr, - hasPageBreakBefore, - normalizeListRenderingAttrs, - buildNumberingPath, - computeWordLayoutForParagraph, -} from './paragraph.js'; +export { computeParagraphAttrs, deepClone } from './paragraph.js'; diff --git a/packages/layout-engine/pm-adapter/src/attributes/paragraph-styles.test.ts b/packages/layout-engine/pm-adapter/src/attributes/paragraph-styles.test.ts deleted file mode 100644 index a108a0f267..0000000000 --- a/packages/layout-engine/pm-adapter/src/attributes/paragraph-styles.test.ts +++ /dev/null @@ -1,1033 +0,0 @@ -import { describe, expect, it, beforeEach, vi } from 'vitest'; -import { hydrateParagraphStyleAttrs, hydrateCharacterStyleAttrs, hydrateMarkerStyleAttrs } from './paragraph-styles.js'; - -const { resolveParagraphProperties, resolveRunProperties, resolveDocxFontFamily } = vi.hoisted(() => ({ - resolveParagraphProperties: vi.fn(), - resolveRunProperties: vi.fn(), - resolveDocxFontFamily: vi.fn(), -})); - -// Mock the shared OOXML resolver module that's imported by paragraph-styles.ts -vi.mock('@superdoc/style-engine/ooxml', () => ({ - createOoxmlResolver: vi.fn(() => ({ - resolveParagraphProperties, - resolveRunProperties, - getDefaultProperties: vi.fn(), - getStyleProperties: vi.fn(), - resolveStyleChain: vi.fn(), - getNumberingProperties: vi.fn(), - })), - resolveDocxFontFamily, -})); - -describe('hydrateParagraphStyleAttrs', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns null when converter context is missing', () => { - const para = { attrs: { styleId: 'Heading1' } } as never; - const result = hydrateParagraphStyleAttrs(para, undefined); - expect(result).toBeNull(); - expect(resolveParagraphProperties).not.toHaveBeenCalled(); - }); - - it('calls resolveParagraphProperties even when paragraph lacks styleId (to apply docDefaults)', () => { - resolveParagraphProperties.mockReturnValue({ - spacing: { after: 200, line: 276, lineRule: 'auto' }, - }); - - const para = { attrs: {} } as never; - const result = hydrateParagraphStyleAttrs(para, { - docx: {}, - numbering: {}, - }); - - expect(resolveParagraphProperties).toHaveBeenCalledWith({ docx: {}, numbering: {} }, { styleId: null }); - expect(result).toEqual( - expect.objectContaining({ - spacing: { after: 200, line: 276, lineRule: 'auto' }, - }), - ); - }); - - it('delegates to resolveParagraphProperties and clones the result', () => { - resolveParagraphProperties.mockReturnValue({ - spacing: { before: 240 }, - indent: { left: 120 }, - borders: { top: { val: 'single', size: 8 } }, - shading: { fill: 'FFEE00' }, - justification: 'center', - tabStops: [{ pos: 100 }], - keepLines: true, - keepNext: false, - numberingProperties: { numId: 9 }, - }); - - const para = { attrs: { styleId: 'Heading1' } } as never; - const result = hydrateParagraphStyleAttrs(para, { - docx: {}, - numbering: {}, - }); - - expect(resolveParagraphProperties).toHaveBeenCalledWith( - { docx: {}, numbering: {} }, - { - styleId: 'Heading1', - numberingProperties: undefined, - indent: undefined, - spacing: undefined, - }, - ); - expect(result).toEqual( - expect.objectContaining({ - spacing: { before: 240 }, - indent: { left: 120 }, - borders: { top: { val: 'single', size: 8 } }, - shading: { fill: 'FFEE00' }, - alignment: 'center', - tabStops: [{ pos: 100 }], - keepLines: true, - keepNext: false, - numberingProperties: { numId: 9 }, - }), - ); - expect(result?.spacing).not.toBe(resolveParagraphProperties.mock.results[0]?.value?.spacing); - }); - - it('zeroes inherited first-line indent for heading styles without explicit indent', () => { - resolveParagraphProperties.mockReturnValue({ - spacing: { after: 200 }, - indent: { firstLine: 432 }, // inherited from Normal - outlineLvl: 1, - }); - - const para = { attrs: { styleId: 'Heading2' } } as never; - const result = hydrateParagraphStyleAttrs(para, { - docx: {}, - numbering: {}, - }); - - expect(result?.indent).toEqual({ firstLine: 0, hanging: 0, left: undefined, right: undefined }); - }); - - it('provides empty numbering fallback when context.numbering is undefined', () => { - resolveParagraphProperties.mockReturnValue({ - spacing: { after: 200, line: 276, lineRule: 'auto' }, - }); - - const para = { attrs: { styleId: 'Normal' } } as never; - hydrateParagraphStyleAttrs(para, { - docx: { styles: {}, docDefaults: {} }, - // numbering is explicitly undefined - should receive { definitions: {}, abstracts: {} } - }); - - expect(resolveParagraphProperties).toHaveBeenCalledWith( - { docx: { styles: {}, docDefaults: {} }, numbering: { definitions: {}, abstracts: {} } }, - expect.objectContaining({ styleId: 'Normal' }), - ); - }); - - it('returns null when resolveParagraphProperties returns null', () => { - resolveParagraphProperties.mockReturnValue(null); - - const para = { attrs: { styleId: 'Heading1' } } as never; - const result = hydrateParagraphStyleAttrs(para, { - docx: {}, - numbering: {}, - }); - - expect(result).toBeNull(); - }); - - it('returns null when resolveParagraphProperties returns undefined', () => { - resolveParagraphProperties.mockReturnValue(undefined); - - const para = { attrs: { styleId: 'Heading1' } } as never; - const result = hydrateParagraphStyleAttrs(para, { - docx: {}, - numbering: {}, - }); - - expect(result).toBeNull(); - }); - - describe('table style paragraph properties cascade', () => { - it('merges table style spacing when paragraph has no explicit spacing (table style wins)', () => { - resolveParagraphProperties.mockReturnValue({ - spacing: { before: 100, after: 100 }, // from docDefaults or paragraph style - }); - - const para = { attrs: {} } as never; // No explicit spacing on paragraph - const result = hydrateParagraphStyleAttrs(para, { - docx: {}, - numbering: {}, - tableStyleParagraphProps: { - spacing: { before: 200, after: 200, line: 1.5, lineRule: 'auto' }, - }, - }); - - // Table style spacing should override resolved spacing (docDefaults) - expect(result?.spacing).toEqual({ - before: 200, - after: 200, - line: 1.5, - lineRule: 'auto', - }); - }); - - it('paragraph explicit spacing wins over table style', () => { - resolveParagraphProperties.mockReturnValue({ - spacing: { before: 300, after: 300, line: 2.0 }, // includes explicit paragraph spacing - }); - - const para = { - attrs: { - spacing: { before: 300, after: 300, line: 2.0 }, // Explicit on paragraph - }, - } as never; - const result = hydrateParagraphStyleAttrs(para, { - docx: {}, - numbering: {}, - tableStyleParagraphProps: { - spacing: { before: 100, after: 100, line: 1.0 }, - }, - }); - - // Paragraph explicit spacing should win, but table style fills in missing values - // Since resolved already has all values, they should win - expect(result?.spacing).toEqual({ - before: 300, - after: 300, - line: 2.0, - }); - }); - - it('partial paragraph spacing: paragraph has some properties, table style fills gaps', () => { - resolveParagraphProperties.mockReturnValue({ - spacing: { line: 1.5 }, // Only line from paragraph style/explicit - }); - - const para = { - attrs: { - spacing: { line: 1.5 }, // Only line is explicit on paragraph - }, - } as never; - const result = hydrateParagraphStyleAttrs(para, { - docx: {}, - numbering: {}, - tableStyleParagraphProps: { - spacing: { before: 100, after: 100, line: 1.0, lineRule: 'auto' }, - }, - }); - - // Table style should provide before/after, but paragraph's line should win - expect(result?.spacing).toEqual({ - before: 100, - after: 100, - line: 1.5, - lineRule: 'auto', - }); - }); - - it('works correctly when tableStyleParagraphProps is undefined (existing behavior)', () => { - resolveParagraphProperties.mockReturnValue({ - spacing: { before: 100, after: 100 }, - indent: { left: 120 }, - }); - - const para = { attrs: {} } as never; - const result = hydrateParagraphStyleAttrs(para, { - docx: {}, - numbering: {}, - // No tableStyleParagraphProps - }); - - // Should use resolved spacing as-is (no table style to merge) - expect(result?.spacing).toEqual({ - before: 100, - after: 100, - }); - expect(result?.indent).toEqual({ left: 120 }); - }); - - it('works correctly when tableStyleParagraphProps.spacing is undefined', () => { - resolveParagraphProperties.mockReturnValue({ - spacing: { before: 100, after: 100 }, - }); - - const para = { attrs: {} } as never; - const result = hydrateParagraphStyleAttrs(para, { - docx: {}, - numbering: {}, - tableStyleParagraphProps: {}, // No spacing in table style - }); - - // Should use resolved spacing as-is - expect(result?.spacing).toEqual({ - before: 100, - after: 100, - }); - }); - }); - - describe('contextualSpacing extraction', () => { - it('extracts contextualSpacing=true from resolved paragraph properties', () => { - resolveParagraphProperties.mockReturnValue({ - spacing: { before: 100, after: 100 }, - contextualSpacing: true, - }); - - const para = { attrs: { styleId: 'ListBullet' } } as never; - const result = hydrateParagraphStyleAttrs(para, { - docx: {}, - numbering: {}, - }); - - expect(result?.contextualSpacing).toBe(true); - }); - - it('extracts contextualSpacing=false from resolved paragraph properties', () => { - resolveParagraphProperties.mockReturnValue({ - spacing: { before: 100, after: 100 }, - contextualSpacing: false, - }); - - const para = { attrs: { styleId: 'Normal' } } as never; - const result = hydrateParagraphStyleAttrs(para, { - docx: {}, - numbering: {}, - }); - - expect(result?.contextualSpacing).toBe(false); - }); - - it('omits contextualSpacing when not present in resolved properties', () => { - resolveParagraphProperties.mockReturnValue({ - spacing: { before: 100, after: 100 }, - // No contextualSpacing property - }); - - const para = { attrs: { styleId: 'Heading1' } } as never; - const result = hydrateParagraphStyleAttrs(para, { - docx: {}, - numbering: {}, - }); - - expect(result?.contextualSpacing).toBeUndefined(); - }); - - it('includes contextualSpacing in hydration result alongside other properties', () => { - resolveParagraphProperties.mockReturnValue({ - spacing: { before: 240, after: 120 }, - indent: { left: 720 }, - contextualSpacing: true, - keepLines: true, - justification: 'left', - }); - - const para = { attrs: { styleId: 'ListBullet' } } as never; - const result = hydrateParagraphStyleAttrs(para, { - docx: {}, - numbering: {}, - }); - - expect(result).toEqual( - expect.objectContaining({ - spacing: { before: 240, after: 120 }, - indent: { left: 720 }, - contextualSpacing: true, - keepLines: true, - alignment: 'left', - }), - ); - }); - - it('handles contextualSpacing from style cascade (ListBullet style example)', () => { - // ListBullet style typically defines contextualSpacing to suppress spacing - // between consecutive list items of the same style - resolveParagraphProperties.mockReturnValue({ - spacing: { before: 0, after: 0 }, - indent: { left: 720, hanging: 360 }, - contextualSpacing: true, // "Don't add space between paragraphs of the same style" - }); - - const para = { attrs: { styleId: 'ListBullet' } } as never; - const result = hydrateParagraphStyleAttrs(para, { - docx: {}, - numbering: {}, - }); - - expect(result?.contextualSpacing).toBe(true); - expect(result?.spacing).toEqual({ before: 0, after: 0 }); - }); - }); -}); - -describe('hydrateCharacterStyleAttrs', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns null when context is missing', () => { - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, undefined); - expect(result).toBeNull(); - expect(resolveRunProperties).not.toHaveBeenCalled(); - }); - - it('returns null when context.docx is missing', () => { - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, {} as never); - expect(result).toBeNull(); - }); - - it('extracts fontFamily, fontSize, color, bold, italic, strike, underline, letterSpacing', () => { - resolveRunProperties.mockReturnValue({ - fontFamily: { ascii: 'Calibri', hAnsi: 'Calibri' }, - fontSize: 22, - color: { val: 'FF0000' }, - bold: true, - italic: false, - strike: true, - underline: { 'w:val': 'single', 'w:color': '0000FF' }, - letterSpacing: 20, - }); - resolveDocxFontFamily.mockReturnValue('Calibri'); - - const para = { attrs: { styleId: 'Normal' } } as never; - const result = hydrateCharacterStyleAttrs(para, { docx: {}, numbering: {} }); - - expect(result).toEqual({ - fontFamily: 'Calibri', - fontSize: 22, - color: 'FF0000', - bold: true, - italic: false, - strike: true, - underline: { type: 'single', color: '0000FF' }, - letterSpacing: 20, - }); - }); - - it('handles missing/null values gracefully', () => { - resolveRunProperties.mockReturnValue({ - fontSize: 20, - }); - - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, { docx: {} }); - - expect(result).toEqual({ - fontFamily: undefined, - fontSize: 20, - color: undefined, - bold: undefined, - italic: undefined, - strike: undefined, - underline: undefined, - letterSpacing: undefined, - }); - }); - - it("uses paragraph's styleId for resolution", () => { - resolveRunProperties.mockReturnValue({ fontSize: 24 }); - - const para = { attrs: { styleId: 'Heading1' } } as never; - hydrateCharacterStyleAttrs(para, { docx: {}, numbering: {} }); - - expect(resolveRunProperties).toHaveBeenCalledWith( - { docx: {}, numbering: {} }, // numbering is provided, so no fallback is applied - {}, - { styleId: 'Heading1' }, - false, - false, - ); - }); - - it('does NOT use paragraphProperties.runProperties as inline run properties (w:pPr/w:rPr is for new text only)', () => { - resolveRunProperties.mockReturnValue({ fontSize: 22 }); - - const para = { - attrs: { - paragraphProperties: { - runProperties: { fontSize: 24, bold: true }, - }, - }, - } as never; - hydrateCharacterStyleAttrs(para, { docx: {} }); - - // inlineRpr should be empty - paragraph's runProperties (w:pPr/w:rPr) is only for new text, - // not for existing runs. Runs without explicit formatting inherit from style cascade only. - expect(resolveRunProperties).toHaveBeenCalledWith( - expect.anything(), - {}, // Empty inline run properties - expect.anything(), - false, - false, - ); - }); - - it('does NOT use attrs.runProperties as inline run properties (w:pPr/w:rPr is for new text only)', () => { - resolveRunProperties.mockReturnValue({ fontSize: 22 }); - - const para = { - attrs: { - runProperties: { italic: true }, - }, - } as never; - hydrateCharacterStyleAttrs(para, { docx: {} }); - - // inlineRpr should be empty - paragraph's runProperties is for new text only - expect(resolveRunProperties).toHaveBeenCalledWith( - expect.anything(), - {}, // Empty inline run properties - expect.anything(), - false, - false, - ); - }); - - it('returns null when resolveRunProperties returns null', () => { - resolveRunProperties.mockReturnValue(null); - - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, { docx: {} }); - - expect(result).toBeNull(); - }); - - it('returns null when resolveRunProperties returns non-object', () => { - resolveRunProperties.mockReturnValue('invalid' as never); - - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, { docx: {} }); - - expect(result).toBeNull(); - }); - - it('returns null when resolveRunProperties throws', () => { - resolveRunProperties.mockImplementation(() => { - throw new Error('Resolution failed'); - }); - - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, { docx: {} }); - - expect(result).toBeNull(); - }); - - it('passes preResolved paragraph properties to resolveRunProperties', () => { - resolveRunProperties.mockReturnValue({ fontSize: 22 }); - - const para = { attrs: {} } as never; - const preResolved = { styleId: 'Custom', spacing: { before: 100 } }; - hydrateCharacterStyleAttrs(para, { docx: {} }, preResolved); - - expect(resolveRunProperties).toHaveBeenCalledWith(expect.anything(), expect.anything(), preResolved, false, false); - }); - - it('includes numberingProperties in pprForChain when present', () => { - resolveRunProperties.mockReturnValue({ fontSize: 22 }); - - const para = { - attrs: { - styleId: 'ListParagraph', - numberingProperties: { numId: 1, ilvl: 0 }, - }, - } as never; - hydrateCharacterStyleAttrs(para, { docx: {} }); - - expect(resolveRunProperties).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - { styleId: 'ListParagraph', numberingProperties: { numId: 1, ilvl: 0 } }, - false, - false, - ); - }); - - it('defaults fontSize to 20 when resolved fontSize is invalid', () => { - resolveRunProperties.mockReturnValue({ - fontSize: 'invalid', - }); - - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, { docx: {} }); - - expect(result?.fontSize).toBe(20); - }); - - it('normalizes boolean properties correctly', () => { - resolveRunProperties.mockReturnValue({ - bold: 1, - italic: '1', - strike: 'true', - fontSize: 22, - }); - - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, { docx: {} }); - - expect(result?.bold).toBe(true); - expect(result?.italic).toBe(true); - expect(result?.strike).toBe(true); - }); - - it('extracts color value correctly', () => { - resolveRunProperties.mockReturnValue({ - color: { val: 'auto' }, - fontSize: 22, - }); - - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, { docx: {} }); - - expect(result?.color).toBeUndefined(); // 'auto' is filtered out - }); - - it('extracts underline with type and color', () => { - resolveRunProperties.mockReturnValue({ - underline: { type: 'double', color: 'FF0000' }, - fontSize: 22, - }); - - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, { docx: {} }); - - expect(result?.underline).toEqual({ type: 'double', color: 'FF0000' }); - }); - - it('provides empty numbering fallback when context.numbering is undefined', () => { - resolveRunProperties.mockReturnValue({ fontSize: 22 }); - - const para = { attrs: {} } as never; - hydrateCharacterStyleAttrs(para, { docx: {} }); - - expect(resolveRunProperties).toHaveBeenCalledWith( - { docx: {}, numbering: { definitions: {}, abstracts: {} } }, - expect.anything(), - expect.anything(), - false, - false, - ); - }); -}); - -describe('hydrateMarkerStyleAttrs', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns null when context is missing', () => { - const para = { attrs: {} } as never; - const result = hydrateMarkerStyleAttrs(para, undefined); - expect(result).toBeNull(); - expect(resolveRunProperties).not.toHaveBeenCalled(); - }); - - it('resolves marker properties with isListNumber=true', () => { - resolveRunProperties.mockReturnValue({ - fontSize: 22, - bold: true, - fontFamily: { ascii: 'Calibri' }, - }); - resolveDocxFontFamily.mockReturnValue('Calibri'); - - const para = { - attrs: { - styleId: 'ListParagraph', - numberingProperties: { numId: 1, ilvl: 0 }, - }, - } as never; - const result = hydrateMarkerStyleAttrs(para, { docx: {}, numbering: {} }); - - expect(resolveRunProperties).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ numberingProperties: { numId: 1, ilvl: 0 } }), - true, // isListNumber - true, // numberingDefinedInline - ); - expect(result).toEqual({ - fontSize: 22, - bold: true, - fontFamily: 'Calibri', - color: undefined, - italic: undefined, - strike: undefined, - underline: undefined, - letterSpacing: undefined, - }); - }); - - it('handles numberingDefinedInline flag correctly', () => { - resolveRunProperties.mockReturnValue({ fontSize: 22 }); - - const para = { - attrs: { - numberingProperties: { numId: 1, ilvl: 0 }, - }, - } as never; - hydrateMarkerStyleAttrs(para, { docx: {} }); - - expect(resolveRunProperties).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.anything(), - true, - true, // numberingDefinedInline = true because numId is in attrs - ); - }); - - it('sets numberingDefinedInline to false when numId is not present', () => { - resolveRunProperties.mockReturnValue({ fontSize: 22 }); - - const para = { attrs: {} } as never; - hydrateMarkerStyleAttrs(para, { docx: {} }); - - expect(resolveRunProperties).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.anything(), - true, - false, // numberingDefinedInline = false - ); - }); - - it('falls back properly when numbering context is empty', () => { - resolveRunProperties.mockReturnValue({ fontSize: 22 }); - - const para = { attrs: {} } as never; - hydrateMarkerStyleAttrs(para, { docx: {} }); - - expect(resolveRunProperties).toHaveBeenCalledWith( - { docx: {}, numbering: { definitions: {}, abstracts: {} } }, - expect.anything(), - expect.anything(), - true, - false, - ); - }); - - it('does NOT use paragraphProperties.runProperties for marker (w:pPr/w:rPr is for new text only)', () => { - resolveRunProperties.mockReturnValue({ fontSize: 22 }); - - const para = { - attrs: { - paragraphProperties: { - runProperties: { bold: true }, - }, - }, - } as never; - hydrateMarkerStyleAttrs(para, { docx: {} }); - - // Marker styling comes from numbering definition rPr, not from paragraph's w:pPr/w:rPr - expect(resolveRunProperties).toHaveBeenCalledWith( - expect.anything(), - {}, // Empty inline run properties - expect.anything(), - true, - false, - ); - }); - - it('uses preResolved paragraph properties', () => { - resolveRunProperties.mockReturnValue({ fontSize: 22 }); - - const para = { attrs: {} } as never; - const preResolved = { styleId: 'ListParagraph', numberingProperties: { numId: 2 } }; - hydrateMarkerStyleAttrs(para, { docx: {} }, preResolved); - - expect(resolveRunProperties).toHaveBeenCalledWith(expect.anything(), expect.anything(), preResolved, true, false); - }); - - it('merges styleId from para when preResolved lacks it', () => { - resolveRunProperties.mockReturnValue({ fontSize: 22 }); - - const para = { attrs: { styleId: 'Heading1' } } as never; - const preResolved = { spacing: { before: 100 } }; - hydrateMarkerStyleAttrs(para, { docx: {} }, preResolved); - - expect(resolveRunProperties).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - { spacing: { before: 100 }, styleId: 'Heading1' }, - true, - false, - ); - }); - - it('returns null when resolveRunProperties returns null', () => { - resolveRunProperties.mockReturnValue(null); - - const para = { attrs: {} } as never; - const result = hydrateMarkerStyleAttrs(para, { docx: {} }); - - expect(result).toBeNull(); - }); - - it('returns null when resolveRunProperties throws', () => { - resolveRunProperties.mockImplementation(() => { - throw new Error('Resolution failed'); - }); - - const para = { attrs: {} } as never; - const result = hydrateMarkerStyleAttrs(para, { docx: {} }); - - expect(result).toBeNull(); - }); - - it('handles numberingProperties from paragraphProperties', () => { - resolveRunProperties.mockReturnValue({ fontSize: 22 }); - - const para = { - attrs: { - paragraphProperties: { - numberingProperties: { numId: 3, ilvl: 1 }, - }, - }, - } as never; - hydrateMarkerStyleAttrs(para, { docx: {} }); - - expect(resolveRunProperties).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ numberingProperties: { numId: 3, ilvl: 1 } }), - true, - true, - ); - }); -}); - -describe('helper functions', () => { - describe('extractColorValue', () => { - // Access the internal functions via module import would require exposing them - // Since they're internal, we test them indirectly through hydrateCharacterStyleAttrs - it('extracts valid color from resolved properties', () => { - resolveRunProperties.mockReturnValue({ - color: { val: 'FF0000' }, - fontSize: 22, - }); - - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, { docx: {} }); - - expect(result?.color).toBe('FF0000'); - }); - - it('ignores auto color', () => { - resolveRunProperties.mockReturnValue({ - color: { val: 'auto' }, - fontSize: 22, - }); - - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, { docx: {} }); - - expect(result?.color).toBeUndefined(); - }); - - it('handles null color', () => { - resolveRunProperties.mockReturnValue({ - color: null, - fontSize: 22, - }); - - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, { docx: {} }); - - expect(result?.color).toBeUndefined(); - }); - - it('handles invalid color object', () => { - resolveRunProperties.mockReturnValue({ - color: { val: 123 }, - fontSize: 22, - }); - - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, { docx: {} }); - - expect(result?.color).toBeUndefined(); - }); - }); - - describe('normalizeBooleanProp', () => { - it('normalizes true boolean', () => { - resolveRunProperties.mockReturnValue({ bold: true, fontSize: 22 }); - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, { docx: {} }); - expect(result?.bold).toBe(true); - }); - - it('normalizes false boolean', () => { - resolveRunProperties.mockReturnValue({ bold: false, fontSize: 22 }); - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, { docx: {} }); - expect(result?.bold).toBe(false); - }); - - it('normalizes 1 to true', () => { - resolveRunProperties.mockReturnValue({ bold: 1, fontSize: 22 }); - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, { docx: {} }); - expect(result?.bold).toBe(true); - }); - - it('normalizes 0 to false', () => { - resolveRunProperties.mockReturnValue({ bold: 0, fontSize: 22 }); - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, { docx: {} }); - expect(result?.bold).toBe(false); - }); - - it('normalizes "1" to true', () => { - resolveRunProperties.mockReturnValue({ bold: '1', fontSize: 22 }); - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, { docx: {} }); - expect(result?.bold).toBe(true); - }); - - it('normalizes "0" to false', () => { - resolveRunProperties.mockReturnValue({ bold: '0', fontSize: 22 }); - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, { docx: {} }); - expect(result?.bold).toBe(false); - }); - - it('normalizes "true" to true', () => { - resolveRunProperties.mockReturnValue({ bold: 'true', fontSize: 22 }); - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, { docx: {} }); - expect(result?.bold).toBe(true); - }); - - it('normalizes "false" to false', () => { - resolveRunProperties.mockReturnValue({ bold: 'false', fontSize: 22 }); - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, { docx: {} }); - expect(result?.bold).toBe(false); - }); - - it('normalizes "on" to true', () => { - resolveRunProperties.mockReturnValue({ bold: 'on', fontSize: 22 }); - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, { docx: {} }); - expect(result?.bold).toBe(true); - }); - - it('normalizes "off" to false', () => { - resolveRunProperties.mockReturnValue({ bold: 'off', fontSize: 22 }); - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, { docx: {} }); - expect(result?.bold).toBe(false); - }); - - it('normalizes empty string to true (OOXML convention)', () => { - resolveRunProperties.mockReturnValue({ bold: '', fontSize: 22 }); - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, { docx: {} }); - expect(result?.bold).toBe(true); - }); - - it('handles null as undefined', () => { - resolveRunProperties.mockReturnValue({ bold: null, fontSize: 22 }); - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, { docx: {} }); - expect(result?.bold).toBeUndefined(); - }); - }); - - describe('extractUnderline', () => { - it('extracts w:val and type', () => { - resolveRunProperties.mockReturnValue({ - underline: { 'w:val': 'single' }, - fontSize: 22, - }); - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, { docx: {} }); - expect(result?.underline).toEqual({ type: 'single', color: undefined }); - }); - - it('extracts type without w: prefix', () => { - resolveRunProperties.mockReturnValue({ - underline: { type: 'double' }, - fontSize: 22, - }); - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, { docx: {} }); - expect(result?.underline).toEqual({ type: 'double', color: undefined }); - }); - - it('extracts val property', () => { - resolveRunProperties.mockReturnValue({ - underline: { val: 'thick' }, - fontSize: 22, - }); - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, { docx: {} }); - expect(result?.underline).toEqual({ type: 'thick', color: undefined }); - }); - - it('extracts w:color', () => { - resolveRunProperties.mockReturnValue({ - underline: { 'w:val': 'single', 'w:color': 'FF0000' }, - fontSize: 22, - }); - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, { docx: {} }); - expect(result?.underline).toEqual({ type: 'single', color: 'FF0000' }); - }); - - it('extracts color without w: prefix', () => { - resolveRunProperties.mockReturnValue({ - underline: { type: 'double', color: '00FF00' }, - fontSize: 22, - }); - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, { docx: {} }); - expect(result?.underline).toEqual({ type: 'double', color: '00FF00' }); - }); - - it('handles "none" underline as undefined', () => { - resolveRunProperties.mockReturnValue({ - underline: { 'w:val': 'none' }, - fontSize: 22, - }); - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, { docx: {} }); - expect(result?.underline).toBeUndefined(); - }); - - it('handles null underline', () => { - resolveRunProperties.mockReturnValue({ - underline: null, - fontSize: 22, - }); - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, { docx: {} }); - expect(result?.underline).toBeUndefined(); - }); - - it('handles invalid underline object', () => { - resolveRunProperties.mockReturnValue({ - underline: {}, - fontSize: 22, - }); - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, { docx: {} }); - expect(result?.underline).toBeUndefined(); - }); - - it('handles non-string color value', () => { - resolveRunProperties.mockReturnValue({ - underline: { type: 'single', color: 123 }, - fontSize: 22, - }); - const para = { attrs: {} } as never; - const result = hydrateCharacterStyleAttrs(para, { docx: {} }); - expect(result?.underline).toEqual({ type: 'single', color: undefined }); - }); - }); -}); diff --git a/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts b/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts index c14e89bf52..99a171afb5 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts @@ -1,3932 +1,144 @@ /** - * Tests for Paragraph Attributes Computation Module + * Tests for Paragraph Attributes Computation Module. * - * Covers 13 exported functions for computing, merging, and normalizing paragraph attributes: - * - resolveParagraphBooleanAttr: Resolve boolean attributes from PM node - * - hasPageBreakBefore: Check for page break before paragraph - * - cloneParagraphAttrs: Deep clone paragraph attributes - * - buildStyleNodeFromAttrs: Build style node for style engine - * - normalizeListRenderingAttrs: Normalize list rendering attributes - * - buildNumberingPath: Build numbering path for multi-level lists - * - computeWordLayoutForParagraph: Compute Word paragraph layout - * - computeParagraphAttrs: Main function for computing paragraph attrs (187 lines) - * - mergeParagraphAttrs: Merge two paragraph attrs - * - convertListParagraphAttrs: Convert list paragraph attrs - * - * Note: Some tests require mocking style-engine and word-layout dependencies. + * This suite focuses on the exported helpers: + * - deepClone + * - normalizeFramePr + * - normalizeDropCap + * - computeParagraphAttrs + * - computeRunAttrs */ -import { describe, it, expect, vi } from 'vitest'; -import type { ParagraphAttrs, ParagraphIndent, ParagraphSpacing } from '@superdoc/contracts'; -import { - resolveParagraphBooleanAttr, - hasPageBreakBefore, - cloneParagraphAttrs, - buildStyleNodeFromAttrs, - normalizeListRenderingAttrs, - buildNumberingPath, - computeWordLayoutForParagraph, - computeParagraphAttrs, - mergeParagraphAttrs, - convertListParagraphAttrs, - mergeSpacingSources, - isValidNumberingId, -} from './paragraph.js'; -import type { ListCounterContext, StyleContext } from '../types.js'; +import { describe, it, expect } from 'vitest'; +import { deepClone, normalizeFramePr, normalizeDropCap, computeParagraphAttrs, computeRunAttrs } from './paragraph.js'; import { twipsToPx } from '../utilities.js'; -/** - * Mock PM node shape for testing. - * This is a minimal subset of the actual PMNode interface used by the functions under test. - * The functions only access `attrs` and optionally `content`, so this simplified type - * is structurally compatible and avoids requiring full ProseMirror node construction. - */ type PMNode = { + type?: { name?: string }; attrs?: Record; - content?: PMNode[]; - type?: string; - text?: string; - marks?: Array<{ type?: string; attrs?: Record }>; + content?: Array<{ + type?: string; + attrs?: Record; + content?: Array<{ type?: string; text?: string }>; + }>; }; -/** - * Creates a minimal StyleContext for testing. - * StyleContext has all optional properties, so an empty object is valid. - * This helper provides better type safety than `as never` type assertions. - */ -const createTestStyleContext = (overrides: Partial = {}): StyleContext => ({ - styles: {}, - defaults: {}, - ...overrides, -}); - -describe('isValidNumberingId', () => { - describe('valid numbering IDs', () => { - it('should return true for positive integer numId', () => { - expect(isValidNumberingId(1)).toBe(true); - expect(isValidNumberingId(5)).toBe(true); - expect(isValidNumberingId(100)).toBe(true); - }); - - it('should return true for positive string numId', () => { - expect(isValidNumberingId('1')).toBe(true); - expect(isValidNumberingId('5')).toBe(true); - expect(isValidNumberingId('100')).toBe(true); - }); - - it('should return true for negative numId values', () => { - // While unusual, negative values are technically valid (not the special zero value) - expect(isValidNumberingId(-1)).toBe(true); - expect(isValidNumberingId('-1')).toBe(true); - }); - }); - - describe('invalid numbering IDs (OOXML spec §17.9.16)', () => { - it('should return false for numeric zero (disables numbering)', () => { - expect(isValidNumberingId(0)).toBe(false); - }); - - it('should return false for string zero (disables numbering)', () => { - expect(isValidNumberingId('0')).toBe(false); - }); - - it('should return false for null', () => { - expect(isValidNumberingId(null)).toBe(false); - }); - - it('should return false for undefined', () => { - expect(isValidNumberingId(undefined)).toBe(false); - }); - }); - - describe('edge cases', () => { - it('should return true for empty string (not the zero sentinel)', () => { - // Empty string is not the same as '0' per OOXML spec - expect(isValidNumberingId('')).toBe(true); - }); - - it('should return true for string with leading zeros', () => { - // '00' is not the same as '0' - expect(isValidNumberingId('00')).toBe(true); - expect(isValidNumberingId('001')).toBe(true); - }); - - it('should return true for floating point numbers', () => { - // While unusual, non-zero floats are not the special zero value - expect(isValidNumberingId(1.5)).toBe(true); - expect(isValidNumberingId(0.1)).toBe(true); - }); +describe('deepClone', () => { + it('creates a deep copy of nested objects and arrays', () => { + const source = { + spacing: { before: 120, after: 240 }, + tabs: [{ val: 'start', pos: 720 }], + }; - it('should return false for string "0.0" (string comparison)', () => { - // String comparison: '0.0' !== '0', so this is technically valid - expect(isValidNumberingId('0.0')).toBe(true); - }); + const result = deepClone(source); - it('should return false for -0 (numeric zero)', () => { - // In JavaScript, -0 === 0 - expect(isValidNumberingId(-0)).toBe(false); - }); + expect(result).toEqual(source); + expect(result).not.toBe(source); + expect(result.spacing).not.toBe(source.spacing); + expect(result.tabs).not.toBe(source.tabs); }); }); -describe('resolveParagraphBooleanAttr', () => { - describe('direct attribute resolution', () => { - it('should return true for boolean true', () => { - const para: PMNode = { attrs: { bidi: true } }; - expect(resolveParagraphBooleanAttr(para, 'bidi', 'w:bidi')).toBe(true); - }); - - it('should return true for number 1', () => { - const para: PMNode = { attrs: { bidi: 1 } }; - expect(resolveParagraphBooleanAttr(para, 'bidi', 'w:bidi')).toBe(true); - }); - - it('should return true for string "true"', () => { - const para: PMNode = { attrs: { bidi: 'true' } }; - expect(resolveParagraphBooleanAttr(para, 'bidi', 'w:bidi')).toBe(true); - }); - - it('should return true for string "1"', () => { - const para: PMNode = { attrs: { bidi: '1' } }; - expect(resolveParagraphBooleanAttr(para, 'bidi', 'w:bidi')).toBe(true); - }); - - it('should return true for string "on"', () => { - const para: PMNode = { attrs: { bidi: 'on' } }; - expect(resolveParagraphBooleanAttr(para, 'bidi', 'w:bidi')).toBe(true); - }); - - it('should return false for boolean false', () => { - const para: PMNode = { attrs: { bidi: false } }; - expect(resolveParagraphBooleanAttr(para, 'bidi', 'w:bidi')).toBe(false); - }); - - it('should return false for number 0', () => { - const para: PMNode = { attrs: { bidi: 0 } }; - expect(resolveParagraphBooleanAttr(para, 'bidi', 'w:bidi')).toBe(false); - }); - - it('should return false for string "false"', () => { - const para: PMNode = { attrs: { bidi: 'false' } }; - expect(resolveParagraphBooleanAttr(para, 'bidi', 'w:bidi')).toBe(false); - }); - - it('should return false for string "0"', () => { - const para: PMNode = { attrs: { bidi: '0' } }; - expect(resolveParagraphBooleanAttr(para, 'bidi', 'w:bidi')).toBe(false); - }); - - it('should return false for string "off"', () => { - const para: PMNode = { attrs: { bidi: 'off' } }; - expect(resolveParagraphBooleanAttr(para, 'bidi', 'w:bidi')).toBe(false); - }); - - it('should handle case-insensitive string values', () => { - expect(resolveParagraphBooleanAttr({ attrs: { bidi: 'TRUE' } }, 'bidi', 'w:bidi')).toBe(true); - expect(resolveParagraphBooleanAttr({ attrs: { bidi: 'FALSE' } }, 'bidi', 'w:bidi')).toBe(false); - expect(resolveParagraphBooleanAttr({ attrs: { bidi: 'On' } }, 'bidi', 'w:bidi')).toBe(true); - expect(resolveParagraphBooleanAttr({ attrs: { bidi: 'Off' } }, 'bidi', 'w:bidi')).toBe(false); - }); - }); +describe('normalizeFramePr', () => { + it('normalizes frame properties and converts positions to pixels', () => { + const framePr = { + wrap: 'around', + x: 720, + y: 1440, + xAlign: 'right', + yAlign: 'center', + hAnchor: 'page', + vAnchor: 'margin', + }; - describe('paragraphProperties resolution', () => { - it('should resolve from nested paragraphProperties', () => { - const para: PMNode = { - attrs: { - paragraphProperties: { - bidi: true, - }, - }, - }; - expect(resolveParagraphBooleanAttr(para, 'bidi', 'w:bidi')).toBe(true); - }); + const result = normalizeFramePr(framePr); - it('should prioritize direct attrs over paragraphProperties', () => { - const para: PMNode = { - attrs: { - bidi: true, - paragraphProperties: { - bidi: false, - }, - }, - }; - expect(resolveParagraphBooleanAttr(para, 'bidi', 'w:bidi')).toBe(true); + expect(result).toEqual({ + wrap: 'around', + x: twipsToPx(720), + y: twipsToPx(1440), + xAlign: 'right', + yAlign: 'center', + hAnchor: 'page', + vAnchor: 'margin', }); }); +}); - describe('element-based resolution', () => { - it('should infer true from element without val attribute', () => { - const para: PMNode = { - attrs: { - paragraphProperties: { - elements: [{ name: 'w:bidi' }], - }, - }, - }; - expect(resolveParagraphBooleanAttr(para, 'bidi', 'w:bidi')).toBe(true); - }); - - it('should infer from element with w:val attribute', () => { - const para: PMNode = { - attrs: { - paragraphProperties: { - elements: [{ name: 'w:bidi', attributes: { 'w:val': 'true' } }], - }, - }, - }; - expect(resolveParagraphBooleanAttr(para, 'bidi', 'w:bidi')).toBe(true); - }); - - it('should handle element name with and without w: prefix', () => { - const para1: PMNode = { - attrs: { - paragraphProperties: { - elements: [{ name: 'w:bidi' }], - }, - }, - }; - const para2: PMNode = { - attrs: { - paragraphProperties: { - elements: [{ name: 'bidi' }], - }, - }, - }; - expect(resolveParagraphBooleanAttr(para1, 'bidi', 'bidi')).toBe(true); - expect(resolveParagraphBooleanAttr(para2, 'bidi', 'w:bidi')).toBe(true); - }); - - it('should handle multiple element names', () => { - const para: PMNode = { - attrs: { - paragraphProperties: { - elements: [{ name: 'w:keepNext' }], - }, +describe('normalizeDropCap', () => { + it('extracts drop cap run info from paragraph content', () => { + const paragraph: PMNode = { + type: { name: 'paragraph' }, + content: [ + { + type: 'run', + attrs: { runProperties: { fontSize: 24, bold: true } }, + content: [{ type: 'text', text: 'A' }], }, - }; - expect(resolveParagraphBooleanAttr(para, 'keepWithNext', ['w:keepNext', 'w:keepWithNext'])).toBe(true); - }); - }); - - describe('undefined cases', () => { - it('should return undefined when attribute not found', () => { - const para: PMNode = { attrs: {} }; - expect(resolveParagraphBooleanAttr(para, 'bidi', 'w:bidi')).toBeUndefined(); - }); + ], + }; - it('should return undefined for para without attrs', () => { - const para: PMNode = {}; - expect(resolveParagraphBooleanAttr(para, 'bidi', 'w:bidi')).toBeUndefined(); - }); + const framePr = { dropCap: 'drop', lines: 2 }; + const result = normalizeDropCap(framePr, paragraph as never); - it('should return undefined for non-boolean values', () => { - const para: PMNode = { attrs: { bidi: 'unknown' } }; - expect(resolveParagraphBooleanAttr(para, 'bidi', 'w:bidi')).toBeUndefined(); - }); + expect(result?.mode).toBe('drop'); + expect(result?.lines).toBe(2); + expect(result?.run?.text).toBe('A'); + expect(result?.run?.bold).toBe(true); + expect(typeof result?.run?.fontSize).toBe('number'); }); }); -describe('hasPageBreakBefore', () => { - it('should return true for direct pageBreakBefore attribute', () => { - const para: PMNode = { attrs: { pageBreakBefore: true } }; - expect(hasPageBreakBefore(para)).toBe(true); - }); - - it('should return true for nested paragraphProperties', () => { - const para: PMNode = { - attrs: { - paragraphProperties: { - pageBreakBefore: true, - }, - }, - }; - expect(hasPageBreakBefore(para)).toBe(true); - }); - - it('should return true for element-based pageBreakBefore', () => { - const para: PMNode = { +describe('computeParagraphAttrs', () => { + it('normalizes spacing, indent, alignment, and tabs from paragraphProperties', () => { + const paragraph: PMNode = { + type: { name: 'paragraph' }, attrs: { paragraphProperties: { - elements: [{ name: 'w:pageBreakBefore' }], + justification: 'center', + spacing: { before: 240, after: 120, line: 2, lineRule: 'auto' }, + indent: { left: 720, hanging: 360 }, + tabStops: [{ val: 'left', pos: 48 }], }, }, }; - expect(hasPageBreakBefore(para)).toBe(true); - }); - - it('should return false when pageBreakBefore is false', () => { - const para: PMNode = { attrs: { pageBreakBefore: false } }; - expect(hasPageBreakBefore(para)).toBe(false); - }); - - it('should return false when pageBreakBefore is not present', () => { - const para: PMNode = { attrs: {} }; - expect(hasPageBreakBefore(para)).toBe(false); - }); - - it('should return false for para without attrs', () => { - const para: PMNode = {}; - expect(hasPageBreakBefore(para)).toBe(false); - }); -}); - -describe('cloneParagraphAttrs', () => { - it('should return undefined for undefined input', () => { - expect(cloneParagraphAttrs(undefined)).toBeUndefined(); - }); - - it('should clone simple attributes', () => { - const attrs: ParagraphAttrs = { - alignment: 'center', - }; - const cloned = cloneParagraphAttrs(attrs); - expect(cloned).toEqual(attrs); - expect(cloned).not.toBe(attrs); - }); - - it('should deep clone spacing', () => { - const attrs: ParagraphAttrs = { - spacing: { before: 10, after: 20, line: 15 }, - }; - const cloned = cloneParagraphAttrs(attrs); - expect(cloned?.spacing).toEqual(attrs.spacing); - expect(cloned?.spacing).not.toBe(attrs.spacing); - }); - - it('should deep clone indent', () => { - const attrs: ParagraphAttrs = { - indent: { left: 10, right: 20, firstLine: 5 }, - }; - const cloned = cloneParagraphAttrs(attrs); - expect(cloned?.indent).toEqual(attrs.indent); - expect(cloned?.indent).not.toBe(attrs.indent); - }); - - it('should deep clone borders', () => { - const attrs: ParagraphAttrs = { - borders: { - top: { style: 'solid', width: 1, color: '#FF0000' }, - bottom: { style: 'dashed', width: 2, color: '#00FF00' }, - }, - }; - const cloned = cloneParagraphAttrs(attrs); - expect(cloned?.borders).toEqual(attrs.borders); - expect(cloned?.borders).not.toBe(attrs.borders); - expect(cloned?.borders?.top).not.toBe(attrs.borders?.top); - expect(cloned?.borders?.bottom).not.toBe(attrs.borders?.bottom); - }); - - it('should deep clone shading', () => { - const attrs: ParagraphAttrs = { - shading: { fill: '#FFFF00', color: '#000000' }, - }; - const cloned = cloneParagraphAttrs(attrs); - expect(cloned?.shading).toEqual(attrs.shading); - expect(cloned?.shading).not.toBe(attrs.shading); - }); - - it('should deep clone tabs array', () => { - const attrs: ParagraphAttrs = { - tabs: [ - { pos: 100, val: 'left' }, - { pos: 200, val: 'center' }, - ], - }; - const cloned = cloneParagraphAttrs(attrs); - expect(cloned?.tabs).toEqual(attrs.tabs); - expect(cloned?.tabs).not.toBe(attrs.tabs); - expect(cloned?.tabs?.[0]).not.toBe(attrs.tabs?.[0]); - }); - - it('should clone complete paragraph attrs', () => { - const attrs: ParagraphAttrs = { - alignment: 'right', - spacing: { before: 10, after: 20 }, - indent: { left: 15, right: 25 }, - borders: { - top: { style: 'solid', width: 1 }, - }, - shading: { fill: '#FFFF00' }, - tabs: [{ pos: 100, val: 'left' }], - }; - const cloned = cloneParagraphAttrs(attrs); - expect(cloned).toEqual(attrs); - expect(cloned).not.toBe(attrs); - }); - - it('should not mutate original attrs', () => { - const attrs: ParagraphAttrs = { - spacing: { before: 10 }, - }; - const cloned = cloneParagraphAttrs(attrs); - if (cloned?.spacing) { - cloned.spacing.before = 999; - } - expect(attrs.spacing?.before).toBe(10); - }); - - it('should handle borders with only some sides', () => { - const attrs: ParagraphAttrs = { - borders: { - top: { style: 'solid', width: 1 }, - }, - }; - const cloned = cloneParagraphAttrs(attrs); - expect(cloned?.borders).toEqual(attrs.borders); - expect(cloned?.borders?.left).toBeUndefined(); - }); - - it('should handle empty borders object', () => { - const attrs: ParagraphAttrs = { - borders: {}, - }; - const cloned = cloneParagraphAttrs(attrs); - expect(cloned?.borders).toBeUndefined(); - }); -}); - -describe('buildStyleNodeFromAttrs', () => { - it('should return empty object for undefined attrs', () => { - const styleNode = buildStyleNodeFromAttrs(undefined); - expect(styleNode).toEqual({}); - }); - - it('should build style node with alignment', () => { - const attrs = { alignment: 'center' }; - const styleNode = buildStyleNodeFromAttrs(attrs); - expect(styleNode.paragraphProps?.alignment).toBe('center'); - }); - - it('should normalize textAlign to alignment', () => { - const attrs = { textAlign: 'right' }; - const styleNode = buildStyleNodeFromAttrs(attrs); - expect(styleNode.paragraphProps?.alignment).toBe('right'); - }); - - it('should include spacing when provided', () => { - const spacing: ParagraphSpacing = { before: 10, after: 20 }; - const styleNode = buildStyleNodeFromAttrs({}, spacing); - expect(styleNode.paragraphProps?.spacing).toBeDefined(); - }); - - it('should include indent when provided', () => { - const indent: ParagraphIndent = { left: 15, right: 25 }; - const styleNode = buildStyleNodeFromAttrs({}, undefined, indent); - expect(styleNode.paragraphProps?.indent).toBeDefined(); - }); - - it('should normalize tabs from attrs.tabs', () => { - const attrs = { - tabs: [{ pos: 100, val: 'left' }], - }; - const styleNode = buildStyleNodeFromAttrs(attrs); - expect(styleNode.paragraphProps?.tabs).toBeDefined(); - }); - - it('should normalize tabs from attrs.tabStops', () => { - const attrs = { - tabStops: [{ pos: 200, val: 'center' }], - }; - const styleNode = buildStyleNodeFromAttrs(attrs); - expect(styleNode.paragraphProps?.tabs).toBeDefined(); - }); - - it('should return empty styleNode when no paragraph props', () => { - const attrs = {}; - const styleNode = buildStyleNodeFromAttrs(attrs); - expect(styleNode).toEqual({}); - }); - - it('should build complete style node', () => { - const attrs = { alignment: 'justify' }; - const spacing: ParagraphSpacing = { before: 10 }; - const indent: ParagraphIndent = { left: 15 }; - const styleNode = buildStyleNodeFromAttrs(attrs, spacing, indent); - expect(styleNode.paragraphProps?.alignment).toBe('justify'); - expect(styleNode.paragraphProps?.spacing).toBeDefined(); - expect(styleNode.paragraphProps?.indent).toBeDefined(); - }); -}); - -describe('normalizeListRenderingAttrs', () => { - it('should return undefined for null', () => { - expect(normalizeListRenderingAttrs(null)).toBeUndefined(); - }); - - it('should return undefined for non-object', () => { - expect(normalizeListRenderingAttrs('string')).toBeUndefined(); - }); - - it('should normalize markerText', () => { - const input = { markerText: '1.' }; - const result = normalizeListRenderingAttrs(input); - expect(result?.markerText).toBe('1.'); - }); - - it('should normalize justification', () => { - expect(normalizeListRenderingAttrs({ justification: 'left' })?.justification).toBe('left'); - expect(normalizeListRenderingAttrs({ justification: 'right' })?.justification).toBe('right'); - expect(normalizeListRenderingAttrs({ justification: 'center' })?.justification).toBe('center'); - }); - - it('should reject invalid justification', () => { - expect(normalizeListRenderingAttrs({ justification: 'invalid' })?.justification).toBeUndefined(); - }); - - it('should normalize numberingType', () => { - const input = { numberingType: 'decimal' }; - const result = normalizeListRenderingAttrs(input); - expect(result?.numberingType).toBe('decimal'); - }); - - it('should normalize suffix', () => { - expect(normalizeListRenderingAttrs({ suffix: 'tab' })?.suffix).toBe('tab'); - expect(normalizeListRenderingAttrs({ suffix: 'space' })?.suffix).toBe('space'); - expect(normalizeListRenderingAttrs({ suffix: 'nothing' })?.suffix).toBe('nothing'); - }); - - it('should reject invalid suffix', () => { - expect(normalizeListRenderingAttrs({ suffix: 'invalid' })?.suffix).toBeUndefined(); - }); - - it('should normalize numeric path array', () => { - const input = { path: [1, 2, 3] }; - const result = normalizeListRenderingAttrs(input); - expect(result?.path).toEqual([1, 2, 3]); - }); - - it('should convert string numbers in path to numbers', () => { - const input = { path: ['1', '2', '3'] }; - const result = normalizeListRenderingAttrs(input); - expect(result?.path).toEqual([1, 2, 3]); - }); - - it('should filter out non-numeric values from path', () => { - const input = { path: [1, 'invalid', 2, NaN, 3] }; - const result = normalizeListRenderingAttrs(input); - expect(result?.path).toEqual([1, 2, 3]); - }); - - it('should return undefined for empty path', () => { - const input = { path: [] }; - const result = normalizeListRenderingAttrs(input); - expect(result?.path).toBeUndefined(); - }); - - it('should normalize complete list rendering attrs', () => { - const input = { - markerText: 'a)', - justification: 'left', - numberingType: 'lowerLetter', - suffix: 'tab', - path: [1, 2], - }; - const result = normalizeListRenderingAttrs(input); - expect(result).toEqual({ - markerText: 'a)', - justification: 'left', - numberingType: 'lowerLetter', - suffix: 'tab', - path: [1, 2], - }); - }); -}); - -describe('buildNumberingPath', () => { - describe('without listCounterContext', () => { - it('should build path with counterValue at target level', () => { - const path = buildNumberingPath(undefined, 0, 5); - expect(path).toEqual([5]); - }); - - it('should build path for level 0', () => { - const path = buildNumberingPath(1, 0, 3); - expect(path).toEqual([3]); - }); - - it('should build path for level 1', () => { - const path = buildNumberingPath(undefined, 1, 3); - expect(path).toEqual([1, 3]); - }); - - it('should build path for level 2', () => { - const path = buildNumberingPath(undefined, 2, 5); - expect(path).toEqual([1, 1, 5]); - }); - - it('should handle negative level as 0', () => { - const path = buildNumberingPath(undefined, -1, 3); - expect(path).toEqual([3]); - }); - - it('should floor fractional levels', () => { - const path = buildNumberingPath(undefined, 2.7, 3); - expect(path).toEqual([1, 1, 3]); - }); - }); - - describe('with listCounterContext', () => { - it('should query parent levels from context', () => { - const context: ListCounterContext = { - getListCounter: vi.fn((numId, level) => { - if (level === 0) return 2; - if (level === 1) return 3; - return 0; - }), - incrementListCounter: vi.fn(), - resetListCounter: vi.fn(), - }; - - const path = buildNumberingPath(1, 2, 7, context); - expect(path).toEqual([2, 3, 7]); - expect(context.getListCounter).toHaveBeenCalledWith(1, 0); - expect(context.getListCounter).toHaveBeenCalledWith(1, 1); - }); - - it('should use 1 for zero or negative parent values', () => { - const context: ListCounterContext = { - getListCounter: vi.fn(() => 0), - incrementListCounter: vi.fn(), - resetListCounter: vi.fn(), - }; - - const path = buildNumberingPath(1, 2, 5, context); - expect(path).toEqual([1, 1, 5]); - }); - - it('should handle level 0 without querying parents', () => { - const context: ListCounterContext = { - getListCounter: vi.fn(), - incrementListCounter: vi.fn(), - resetListCounter: vi.fn(), - }; - - const path = buildNumberingPath(1, 0, 3, context); - expect(path).toEqual([3]); - expect(context.getListCounter).not.toHaveBeenCalled(); - }); - }); -}); - -describe('mergeParagraphAttrs', () => { - it('should return undefined when both are undefined', () => { - expect(mergeParagraphAttrs(undefined, undefined)).toBeUndefined(); - }); - - it('should return override when base is undefined', () => { - const override: ParagraphAttrs = { alignment: 'center' }; - expect(mergeParagraphAttrs(undefined, override)).toBe(override); - }); - - it('should return base when override is undefined', () => { - const base: ParagraphAttrs = { alignment: 'left' }; - expect(mergeParagraphAttrs(base, undefined)).toBe(base); - }); - - it('should override alignment', () => { - const base: ParagraphAttrs = { alignment: 'left' }; - const override: ParagraphAttrs = { alignment: 'right' }; - const merged = mergeParagraphAttrs(base, override); - expect(merged?.alignment).toBe('right'); - }); - it('should merge spacing properties', () => { - const base: ParagraphAttrs = { - spacing: { before: 10, after: 20 }, - }; - const override: ParagraphAttrs = { - spacing: { after: 30, line: 15 }, - }; - const merged = mergeParagraphAttrs(base, override); - expect(merged?.spacing).toEqual({ before: 10, after: 30, line: 15 }); - }); + const { paragraphAttrs } = computeParagraphAttrs(paragraph as never); - it('should merge indent properties', () => { - const base: ParagraphAttrs = { - indent: { left: 10, right: 20 }, - }; - const override: ParagraphAttrs = { - indent: { right: 30, firstLine: 5 }, - }; - const merged = mergeParagraphAttrs(base, override); - expect(merged?.indent).toEqual({ left: 10, right: 30, firstLine: 5 }); + expect(paragraphAttrs.alignment).toBe('center'); + expect(paragraphAttrs.spacing?.before).toBe(twipsToPx(240)); + expect(paragraphAttrs.spacing?.after).toBe(twipsToPx(120)); + expect(paragraphAttrs.spacing?.line).toBe(2); + expect(paragraphAttrs.indent?.left).toBe(twipsToPx(720)); + expect(paragraphAttrs.indent?.hanging).toBe(twipsToPx(360)); + expect(paragraphAttrs.tabs?.[0]).toEqual({ val: 'start', pos: 720 }); }); - it('should merge borders', () => { - const base: ParagraphAttrs = { - borders: { - top: { style: 'solid', width: 1 }, - }, - }; - const override: ParagraphAttrs = { - borders: { - bottom: { style: 'dashed', width: 2 }, + it('exposes resolved paragraph properties when no converter context is provided', () => { + const paragraph: PMNode = { + type: { name: 'paragraph' }, + attrs: { + paragraphProperties: { styleId: 'Heading1' }, }, }; - const merged = mergeParagraphAttrs(base, override); - expect(merged?.borders?.top).toEqual({ style: 'solid', width: 1 }); - expect(merged?.borders?.bottom).toEqual({ style: 'dashed', width: 2 }); - }); - - it('should merge shading', () => { - const base: ParagraphAttrs = { - shading: { fill: '#FF0000' }, - }; - const override: ParagraphAttrs = { - shading: { color: '#00FF00' }, - }; - const merged = mergeParagraphAttrs(base, override); - expect(merged?.shading).toEqual({ fill: '#FF0000', color: '#00FF00' }); - }); - it('should not mutate base or override', () => { - const base: ParagraphAttrs = { alignment: 'left', spacing: { before: 10 } }; - const override: ParagraphAttrs = { alignment: 'right', spacing: { after: 20 } }; - const originalBase = { ...base, spacing: { ...base.spacing } }; - const originalOverride = { ...override, spacing: { ...override.spacing } }; - - mergeParagraphAttrs(base, override); - - expect(base.alignment).toBe(originalBase.alignment); - expect(override.alignment).toBe(originalOverride.alignment); + const { resolvedParagraphProperties } = computeParagraphAttrs(paragraph as never); + expect(resolvedParagraphProperties.styleId).toBe('Heading1'); }); }); -describe('convertListParagraphAttrs', () => { - it('should return undefined for undefined attrs', () => { - expect(convertListParagraphAttrs(undefined)).toBeUndefined(); - }); - - it('should return undefined for empty attrs', () => { - expect(convertListParagraphAttrs({})).toBeUndefined(); - }); - - it('should convert alignment from attrs.alignment', () => { - const attrs = { alignment: 'center' }; - const result = convertListParagraphAttrs(attrs); - expect(result?.alignment).toBe('center'); - }); - - it('should convert alignment from attrs.lvlJc', () => { - const attrs = { lvlJc: 'right' }; - const result = convertListParagraphAttrs(attrs); - expect(result?.alignment).toBe('right'); - }); - - it('should prioritize alignment over lvlJc', () => { - const attrs = { alignment: 'center', lvlJc: 'right' }; - const result = convertListParagraphAttrs(attrs); - expect(result?.alignment).toBe('center'); - }); - - it('should convert spacing', () => { - const attrs = { - spacing: { before: 150, after: 300 }, // 10px and 20px in twips +describe('computeRunAttrs', () => { + it('normalizes font family, font size, and color', () => { + const runProps = { + fontFamily: { ascii: 'Arial' }, + fontSize: 24, + color: { val: 'ff0000' }, }; - const result = convertListParagraphAttrs(attrs); - expect(result?.spacing).toEqual({ before: 10, after: 20 }); - }); - - it('should convert shading', () => { - const attrs = { - shading: { fill: '#FFFF00' }, - }; - const result = convertListParagraphAttrs(attrs); - expect(result?.shading).toEqual({ fill: '#FFFF00' }); - }); - - it('should convert complete list paragraph attrs', () => { - const attrs = { - alignment: 'justify', - spacing: { before: 75 }, // 5px in twips - shading: { fill: '#FF0000' }, - }; - const result = convertListParagraphAttrs(attrs); - expect(result).toEqual({ - alignment: 'justify', - spacing: { before: 5 }, - shading: { fill: '#FF0000' }, - }); - }); -}); -describe('computeWordLayoutForParagraph', () => { - it('should return null on error', () => { - // This will cause computeWordParagraphLayout to throw - const paragraphAttrs: ParagraphAttrs = {}; - const numberingProps = null; // Invalid - const styleContext = createTestStyleContext(); - - const result = computeWordLayoutForParagraph(paragraphAttrs, numberingProps, styleContext); - expect(result).toBeNull(); - }); + const result = computeRunAttrs(runProps as never); - it('should handle paragraphAttrs without indent', () => { - const paragraphAttrs: ParagraphAttrs = { - alignment: 'left', - }; - const numberingProps = { - numId: 1, - ilvl: 0, - }; - const styleContext = createTestStyleContext({ - defaults: { - defaultTabIntervalTwips: 720, - decimalSeparator: '.', - }, - }); - - const result = computeWordLayoutForParagraph(paragraphAttrs, numberingProps, styleContext); - // Result depends on computeWordParagraphLayout implementation - // We're just testing it doesn't throw - expect(result).toBeDefined(); - }); - - it('should merge resolvedLevelIndent with paragraph indent', () => { - const paragraphAttrs: ParagraphAttrs = { - indent: { left: 10 }, - }; - const numberingProps = { - numId: 1, - ilvl: 0, - resolvedLevelIndent: { left: 1440 }, // 1 inch in twips - }; - const styleContext = createTestStyleContext({ - defaults: { - defaultTabIntervalTwips: 720, - decimalSeparator: '.', - }, - }); - - const result = computeWordLayoutForParagraph(paragraphAttrs, numberingProps, styleContext); - expect(result).toBeDefined(); - }); - - it('should use default values from styleContext', () => { - const paragraphAttrs: ParagraphAttrs = {}; - const numberingProps = { numId: 1, ilvl: 0 }; - const styleContext = createTestStyleContext({ - defaults: { - defaultTabIntervalTwips: 360, - decimalSeparator: ',', - }, - }); - - const result = computeWordLayoutForParagraph(paragraphAttrs, numberingProps, styleContext); - expect(result).toBeDefined(); - }); -}); - -describe('computeParagraphAttrs', () => { - // Note: Full testing of computeParagraphAttrs requires mocking resolveStyle and other dependencies - // These tests cover basic scenarios - - it('should return undefined for para without attrs', () => { - const para: PMNode = {}; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - // May return undefined or minimal attrs depending on style resolution - expect(result).toBeDefined(); - }); - - it('should set direction and rtl for bidi paragraphs', () => { - const para: PMNode = { - attrs: { bidi: true }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - expect(result?.direction).toBe('rtl'); - expect(result?.rtl).toBe(true); - }); - - it('should default bidi paragraphs to right alignment', () => { - const para: PMNode = { - attrs: { bidi: true }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - expect(result?.alignment).toBe('right'); - }); - - it('should respect explicit alignment over bidi default', () => { - const para: PMNode = { - attrs: { bidi: true, alignment: 'center' }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - expect(result?.alignment).toBe('center'); - }); - - it('should keep explicit indent when numbering resolvedLevelIndent is present', () => { - const para: PMNode = { - attrs: { - indent: { left: 24, firstLine: 12 }, - numberingProperties: { - numId: 1, - ilvl: 2, - resolvedLevelIndent: { left: 1440, hanging: 720 }, - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - // Explicit indent (24px) should not be replaced by the numbering indent (~96px) - expect(result?.indent?.left).toBeDefined(); - expect(result?.indent?.left).toBeLessThan(twipsToPx(1440)); - expect(result?.indent?.left).toBeCloseTo(24); - }); - - it('converts small twips indent values from paragraphProperties', () => { - const para: PMNode = { - attrs: { - paragraphProperties: { - indent: { - firstLine: 14, - }, - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - expect(result?.indent?.firstLine).toBeCloseTo(twipsToPx(14)); - }); - - it('merges style-based firstLine indent with inline right indent', () => { - const para: PMNode = { - attrs: { - paragraphProperties: { - indent: { - right: 360, // 0.25in in twips - }, - }, - }, - }; - const styleContext = createTestStyleContext(); - const hydrationOverride = { - indent: { - firstLine: 720, // 0.5in in twips - }, - }; - - const result = computeParagraphAttrs(para, styleContext, undefined, undefined, hydrationOverride); - - expect(result?.indent?.firstLine).toBeCloseTo(twipsToPx(720)); - expect(result?.indent?.right).toBeCloseTo(twipsToPx(360)); - }); - - it('should not force first-line indent mode when paragraph overrides numbering firstLine', () => { - const para: PMNode = { - attrs: { - indent: { left: 0, firstLine: 0 }, - numberingProperties: { - numId: 1, - ilvl: 0, - resolvedLevelIndent: { left: 0, firstLine: 2160 }, - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.wordLayout?.firstLineIndentMode).not.toBe(true); - expect(result?.wordLayout?.textStartPx).toBe(0); - expect(result?.wordLayout?.marker?.textStartX).toBe(0); - }); - - it('should normalize paragraph borders', () => { - const para: PMNode = { - attrs: { - borders: { - top: { val: 'single', size: 2, color: 'FF0000' }, - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - expect(result?.borders).toBeDefined(); - }); - - it('should normalize paragraph shading', () => { - const para: PMNode = { - attrs: { - shading: { fill: '#FFFF00' }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - expect(result?.shading).toBeDefined(); - }); - - it('should include custom decimalSeparator', () => { - const para: PMNode = { attrs: {} }; - const styleContext = createTestStyleContext({ - defaults: { - decimalSeparator: ',', - }, - }); - - const result = computeParagraphAttrs(para, styleContext); - expect(result?.decimalSeparator).toBe(','); - }); - - it('should extract floatAlignment from framePr', () => { - const para: PMNode = { - attrs: { - framePr: { xAlign: 'right' }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - expect(result?.floatAlignment).toBe('right'); - }); - - it('should surface frame positioning data from framePr', () => { - const para: PMNode = { - attrs: { - framePr: { xAlign: 'right', wrap: 'none', y: 1440, hAnchor: 'margin', vAnchor: 'text' }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - expect(result?.frame?.wrap).toBe('none'); - expect(result?.frame?.xAlign).toBe('right'); - expect(result?.frame?.vAnchor).toBe('text'); - expect(result?.frame?.hAnchor).toBe('margin'); - expect(result?.frame?.y).toBeCloseTo(twipsToPx(1440)); - }); - - it('should handle framePr in paragraphProperties (raw OOXML elements)', () => { - const para: PMNode = { - attrs: { - paragraphProperties: { - elements: [ - { - name: 'w:framePr', - attributes: { 'w:xAlign': 'center' }, - }, - ], - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - expect(result?.floatAlignment).toBe('center'); - }); - - it('should handle framePr in paragraphProperties (decoded object from v3 translator)', () => { - // This is the format produced by the v3 translator when importing DOCX - // Headers/footers with right-aligned page numbers use this structure - const para: PMNode = { - attrs: { - paragraphProperties: { - framePr: { xAlign: 'right', wrap: 'none', hAnchor: 'margin', vAnchor: 'text', y: 1440 }, - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - expect(result?.floatAlignment).toBe('right'); - expect(result?.frame?.wrap).toBe('none'); - expect(result?.frame?.xAlign).toBe('right'); - expect(result?.frame?.hAnchor).toBe('margin'); - expect(result?.frame?.vAnchor).toBe('text'); - expect(result?.frame?.y).toBeCloseTo(twipsToPx(1440)); - }); - - it('should handle numberingProperties with list counter', () => { - const para: PMNode = { - attrs: { - numberingProperties: { - numId: 1, - ilvl: 0, - }, - }, - }; - const styleContext = createTestStyleContext(); - const listCounterContext: ListCounterContext = { - getListCounter: vi.fn(() => 0), - incrementListCounter: vi.fn(() => 1), - resetListCounter: vi.fn(), - }; - - const result = computeParagraphAttrs(para, styleContext, listCounterContext); - expect(result?.numberingProperties).toBeDefined(); - expect(listCounterContext.incrementListCounter).toHaveBeenCalledWith(1, 0); - }); - - describe('numId=0 disables numbering (OOXML spec §17.9.16)', () => { - it('should not create numberingProperties when numId is numeric 0', () => { - const para: PMNode = { - attrs: { - numberingProperties: { - numId: 0, - ilvl: 0, - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - // numId=0 disables numbering, so numberingProperties should not be set - expect(result?.numberingProperties).toBeUndefined(); - expect(result?.wordLayout).toBeUndefined(); - }); - - it('should not create numberingProperties when numId is string "0"', () => { - const para: PMNode = { - attrs: { - numberingProperties: { - numId: '0', - ilvl: 0, - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - // numId='0' disables numbering, so numberingProperties should not be set - expect(result?.numberingProperties).toBeUndefined(); - expect(result?.wordLayout).toBeUndefined(); - }); - - it('should not increment list counter when numId is 0', () => { - const para: PMNode = { - attrs: { - numberingProperties: { - numId: 0, - ilvl: 0, - }, - }, - }; - const styleContext = createTestStyleContext(); - const listCounterContext: ListCounterContext = { - getListCounter: vi.fn(() => 0), - incrementListCounter: vi.fn(() => 1), - resetListCounter: vi.fn(), - }; - - computeParagraphAttrs(para, styleContext, listCounterContext); - - // numId=0 should skip list counter logic entirely - expect(listCounterContext.incrementListCounter).not.toHaveBeenCalled(); - expect(listCounterContext.resetListCounter).not.toHaveBeenCalled(); - }); - - it('should not increment list counter when numId is "0"', () => { - const para: PMNode = { - attrs: { - numberingProperties: { - numId: '0', - ilvl: 2, - }, - }, - }; - const styleContext = createTestStyleContext(); - const listCounterContext: ListCounterContext = { - getListCounter: vi.fn(() => 0), - incrementListCounter: vi.fn(() => 1), - resetListCounter: vi.fn(), - }; - - computeParagraphAttrs(para, styleContext, listCounterContext); - - // numId='0' should skip list counter logic entirely - expect(listCounterContext.incrementListCounter).not.toHaveBeenCalled(); - expect(listCounterContext.resetListCounter).not.toHaveBeenCalled(); - }); - - it('should create numberingProperties for valid numId=1', () => { - const para: PMNode = { - attrs: { - numberingProperties: { - numId: 1, - ilvl: 0, - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - // Valid numId should create numberingProperties - expect(result?.numberingProperties).toBeDefined(); - expect(result?.numberingProperties?.numId).toBe(1); - }); - - it('should create numberingProperties for valid numId="5"', () => { - const para: PMNode = { - attrs: { - numberingProperties: { - numId: '5', - ilvl: 1, - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - // Valid string numId should create numberingProperties - expect(result?.numberingProperties).toBeDefined(); - expect(result?.numberingProperties?.numId).toBe('5'); - }); - - it('should skip word layout processing when numId is 0', () => { - const para: PMNode = { - attrs: { - numberingProperties: { - numId: 0, - ilvl: 0, - format: 'decimal', - lvlText: '%1.', - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - // numId=0 should skip word layout entirely - expect(result?.wordLayout).toBeUndefined(); - }); - - it('should skip word layout processing when numId is "0"', () => { - const para: PMNode = { - attrs: { - numberingProperties: { - numId: '0', - ilvl: 1, - format: 'lowerLetter', - lvlText: '%1)', - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - // numId='0' should skip word layout entirely - expect(result?.wordLayout).toBeUndefined(); - }); - }); - - it('should reset deeper list levels', () => { - const para: PMNode = { - attrs: { - numberingProperties: { - numId: 1, - ilvl: 2, - }, - }, - }; - const styleContext = createTestStyleContext(); - const listCounterContext: ListCounterContext = { - getListCounter: vi.fn(() => 1), - incrementListCounter: vi.fn(() => 3), - resetListCounter: vi.fn(), - }; - - computeParagraphAttrs(para, styleContext, listCounterContext); - - // Should reset levels 3-8 - expect(listCounterContext.resetListCounter).toHaveBeenCalled(); - // Access mock.calls through the vitest Mock interface - const resetMock = vi.mocked(listCounterContext.resetListCounter); - const resetCalls = resetMock.mock.calls; - expect(resetCalls.length).toBeGreaterThan(0); - expect(resetCalls.some((call) => call[1] === 3)).toBe(true); - }); - - it('hydrates numbering details from converterContext definitions', () => { - const para: PMNode = { - attrs: { - numberingProperties: { numId: 7, ilvl: 1 }, - }, - }; - const styleContext = createTestStyleContext({ - defaults: { defaultTabIntervalTwips: 720, decimalSeparator: '.' }, - }); - const converterContext = { - numbering: { - definitions: { - '7': { - name: 'w:num', - attributes: { 'w:numId': '7' }, - elements: [{ name: 'w:abstractNumId', attributes: { 'w:val': '3' } }], - }, - }, - abstracts: { - '3': { - name: 'w:abstractNum', - attributes: { 'w:abstractNumId': '3' }, - elements: [ - { - name: 'w:lvl', - attributes: { 'w:ilvl': '1' }, - elements: [ - { name: 'w:start', attributes: { 'w:val': '1' } }, - { name: 'w:numFmt', attributes: { 'w:val': 'lowerLetter' } }, - { name: 'w:lvlText', attributes: { 'w:val': '%2.' } }, - { name: 'w:lvlJc', attributes: { 'w:val': 'left' } }, - { name: 'w:suff', attributes: { 'w:val': 'space' } }, - { - name: 'w:pPr', - elements: [{ name: 'w:ind', attributes: { 'w:left': '1440', 'w:hanging': '360' } }], - }, - { - name: 'w:rPr', - elements: [ - { name: 'w:rFonts', attributes: { 'w:ascii': 'Arial' } }, - { name: 'w:color', attributes: { 'w:val': '5C5C5F' } }, - { name: 'w:sz', attributes: { 'w:val': '16' } }, - ], - }, - ], - }, - ], - }, - }, - }, - }; - - const result = computeParagraphAttrs(para, styleContext, undefined, converterContext); - - expect(result?.numberingProperties?.format).toBe('lowerLetter'); - expect(result?.numberingProperties?.lvlText).toBe('%2.'); - expect(result?.numberingProperties?.start).toBe(1); - expect(result?.numberingProperties?.lvlJc).toBe('left'); - expect(result?.numberingProperties?.suffix).toBe('space'); - expect(result?.numberingProperties?.resolvedLevelIndent).toEqual({ left: 1440, hanging: 360 }); - expect(result?.wordLayout?.marker?.markerText).toBe('a.'); - - const markerRun = (result?.numberingProperties as Record)?.resolvedMarkerRpr as - | Record - | undefined; - expect(markerRun?.fontFamily).toBe('Arial'); - }); - - describe('unwrapTabStops function', () => { - // Note: unwrapTabStops is a private function inside computeParagraphAttrs - // We test it indirectly through computeParagraphAttrs by passing various tabStops formats - - it('should unwrap nested tab format { tab: { tabType, pos } }', () => { - const para: PMNode = { - attrs: { - tabs: [{ tab: { tabType: 'start', pos: 2880 } }], // Use value > 1000 so it stays as twips - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.tabs).toBeDefined(); - expect(result?.tabs?.[0].val).toBe('start'); - expect(result?.tabs?.[0].pos).toBe(2880); // Stays as twips (> 1000 threshold) - }); - - it('should handle direct format { val, pos }', () => { - const para: PMNode = { - attrs: { - tabs: [{ val: 'center', pos: 1440 }], - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.tabs).toBeDefined(); - expect(result?.tabs?.[0].val).toBe('center'); - expect(result?.tabs?.[0].pos).toBe(1440); - }); - - it('should skip invalid entries with missing required fields', () => { - const para: PMNode = { - attrs: { - tabs: [ - { val: 'start' }, // Missing pos - { pos: 720 }, // Missing val - { val: 'center', pos: 1440 }, // Valid - ], - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.tabs).toBeDefined(); - expect(result?.tabs).toHaveLength(1); - expect(result?.tabs?.[0].val).toBe('center'); - }); - - it('should add originalPos when extracting from nested format', () => { - const para: PMNode = { - attrs: { - tabs: [{ tab: { tabType: 'start', pos: 4320 } }], // Use value > 1000 so it stays as twips - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.tabs).toBeDefined(); - expect(result?.tabs?.[0].pos).toBe(4320); // Stays as twips (> 1000 threshold) - // The originalPos is set internally during unwrapping - }); - - it('should handle mixed valid and invalid entries', () => { - const para: PMNode = { - attrs: { - tabs: [ - { tab: { tabType: 'start', pos: 2880 } }, // Valid nested (> 1000 threshold) - null, // Invalid: null - { val: 'center', pos: 1440 }, // Valid direct - 'invalid', // Invalid: string - { tab: 'invalid' }, // Invalid: tab is not an object - { val: 'end', pos: 2160 }, // Valid direct - ], - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.tabs).toBeDefined(); - expect(result?.tabs).toHaveLength(3); - // Note: tabs are now sorted by position, so order is 1440, 2160, 2880 - expect(result?.tabs?.[0].val).toBe('center'); - expect(result?.tabs?.[0].pos).toBe(1440); - expect(result?.tabs?.[1].val).toBe('end'); - expect(result?.tabs?.[1].pos).toBe(2160); - expect(result?.tabs?.[2].val).toBe('start'); - expect(result?.tabs?.[2].pos).toBe(2880); - }); - - it('should return undefined for non-array input', () => { - const para: PMNode = { - attrs: { - tabs: 'not an array', - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - // When tabs is not an array, unwrapTabStops returns undefined - // computeParagraphAttrs may still set tabs from other sources - expect(result).toBeDefined(); - }); - - it('should handle nested format with originalPos', () => { - const para: PMNode = { - attrs: { - tabs: [{ tab: { tabType: 'start', pos: 500, originalPos: 720 } }], - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.tabs).toBeDefined(); - expect(result?.tabs?.[0].pos).toBe(720); // Uses originalPos - }); - - it('should handle nested format with leader', () => { - const para: PMNode = { - attrs: { - tabs: [{ tab: { tabType: 'end', pos: 1440, leader: 'dot' } }], - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.tabs).toBeDefined(); - expect(result?.tabs?.[0].val).toBe('end'); - expect(result?.tabs?.[0].leader).toBe('dot'); - }); - - it('should skip entries with invalid nested tab structure', () => { - const para: PMNode = { - attrs: { - tabs: [ - { tab: null }, // Invalid: tab is null - { tab: { tabType: 'start', pos: 2880 } }, // Valid (> 1000 threshold) - { tab: { pos: 1440 } }, // Invalid: missing tabType - { tab: { tabType: 'center' } }, // Invalid: missing pos - ], - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.tabs).toBeDefined(); - expect(result?.tabs).toHaveLength(1); - expect(result?.tabs?.[0].val).toBe('start'); - }); - - it('should handle empty array', () => { - const para: PMNode = { - attrs: { - tabs: [], - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - // Empty array returns undefined from unwrapTabStops - expect(result).toBeDefined(); - }); - - it('should handle direct format with val property fallback', () => { - const para: PMNode = { - attrs: { - tabs: [{ tab: { val: 'start', pos: 2880 } }], // val instead of tabType in nested format (> 1000 threshold) - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.tabs).toBeDefined(); - expect(result?.tabs?.[0].val).toBe('start'); - }); - - it('should preserve leader in direct format', () => { - const para: PMNode = { - attrs: { - tabs: [{ val: 'decimal', pos: 2880, leader: 'hyphen' }], - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.tabs).toBeDefined(); - expect(result?.tabs?.[0].leader).toBe('hyphen'); - }); - }); - - describe('mergeTabStopSources behavior', () => { - // Note: mergeTabStopSources is an internal function that merges tab stops from multiple sources. - // We test it indirectly through computeParagraphAttrs by providing tabs in attrs, hydrated, and paragraphProps. - - it('should merge tab stops from attrs and hydrated sources', () => { - const para: PMNode = { - attrs: { - tabs: [{ val: 'left', pos: 1440 }], // Tab at position 1440 - }, - }; - const styleContext = createTestStyleContext({ - styles: { - testStyle: { - type: 'paragraph', - paragraphProps: { - tabStops: [{ val: 'center', pos: 2880 }], // Tab at position 2880 - }, - }, - }, - }); - - // Pass hydrated props via a style reference - para.attrs = { - ...para.attrs, - styleId: 'testStyle', - }; - - const result = computeParagraphAttrs(para, styleContext); - - // Should have both tabs merged - expect(result?.tabs).toBeDefined(); - expect(result?.tabs?.length).toBeGreaterThanOrEqual(1); - }); - - it('should override tab stops at same position with later source', () => { - // When multiple sources have tabs at the same position, later sources win - // Merge order is: hydratedTabStops, paragraphTabStops, attrTabStops - // So attrTabStops (last) should override earlier sources - const para: PMNode = { - attrs: { - tabs: [{ val: 'decimal', pos: 1440 }], // Same position as style, different alignment - styleId: 'testStyle', - }, - }; - const styleContext = createTestStyleContext({ - styles: { - testStyle: { - type: 'paragraph', - paragraphProps: { - tabStops: [{ val: 'center', pos: 1440 }], // Same position - }, - }, - }, - }); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.tabs).toBeDefined(); - // attrTabStops is processed last, so 'decimal' should override 'center' - const tab1440 = result?.tabs?.find((t) => t.pos === 1440); - expect(tab1440?.val).toBe('decimal'); - }); - - it('should sort merged tab stops by position', () => { - const para: PMNode = { - attrs: { - // Just test sorting with direct tabs from attrs - tabs: [ - { val: 'end', pos: 4320 }, // Third position - { val: 'center', pos: 2880 }, // Second position - { val: 'start', pos: 1440 }, // First position - ], - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.tabs).toBeDefined(); - expect(result?.tabs?.length).toBe(3); - // Should be sorted by position - expect(result?.tabs?.[0].pos).toBe(1440); - expect(result?.tabs?.[0].val).toBe('start'); - expect(result?.tabs?.[1].pos).toBe(2880); - expect(result?.tabs?.[1].val).toBe('center'); - expect(result?.tabs?.[2].pos).toBe(4320); - expect(result?.tabs?.[2].val).toBe('end'); - }); - - it('should handle getTabStopPosition with originalPos property', () => { - const para: PMNode = { - attrs: { - tabs: [{ val: 'left', originalPos: 1440, pos: 100 }], // originalPos takes priority - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.tabs).toBeDefined(); - // The merge uses originalPos for deduplication key - expect(result?.tabs?.[0].pos).toBe(1440); // Uses originalPos - }); - - it('should handle getTabStopPosition with position property', () => { - const para: PMNode = { - attrs: { - tabStops: [{ val: 'left', position: 2880 }], // Uses position property - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.tabs).toBeDefined(); - }); - - it('should handle getTabStopPosition with offset property', () => { - const para: PMNode = { - attrs: { - tabStops: [{ val: 'center', offset: 1440 }], // Uses offset property - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.tabs).toBeDefined(); - }); - - it('should skip tab stops without valid position', () => { - const para: PMNode = { - attrs: { - tabs: [ - { val: 'left' }, // No position - should be skipped by merge - { val: 'center', pos: 1440 }, // Valid - ], - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.tabs).toBeDefined(); - // Only the valid tab should be included - expect(result?.tabs?.length).toBe(1); - expect(result?.tabs?.[0].val).toBe('center'); - }); - - it('should deduplicate tabs at same position from different sources', () => { - const para: PMNode = { - attrs: { - tabs: [ - { val: 'center', pos: 1440 }, - { val: 'decimal', pos: 1440 }, // Same position, should be deduplicated - ], - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.tabs).toBeDefined(); - // Should only have one tab at position 1440 (last one wins) - const tabs1440 = result?.tabs?.filter((t) => t.pos === 1440); - expect(tabs1440?.length).toBe(1); - expect(tabs1440?.[0].val).toBe('decimal'); - }); - - it('should deduplicate tabs that normalize to the same position', () => { - const para: PMNode = { - attrs: { - tabs: [{ val: 'decimal', pos: 96 }], // 96px -> 1440 twips - styleId: 'testStyle', - }, - }; - const styleContext = createTestStyleContext({ - styles: { - testStyle: { - type: 'paragraph', - paragraphProps: { - tabStops: [{ val: 'center', pos: 1440 }], // Same position in twips - }, - }, - }, - }); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.tabs).toBeDefined(); - const tabs1440 = result?.tabs?.filter((t) => t.pos === 1440); - expect(tabs1440?.length).toBe(1); - expect(tabs1440?.[0].val).toBe('decimal'); - }); - - it('should return undefined when all sources are empty or invalid', () => { - const para: PMNode = { - attrs: { - tabs: [], - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - // No tabs should be set when all sources are empty - expect(result?.tabs).toBeUndefined(); - }); - - it('should handle non-object entries in tab array', () => { - const para: PMNode = { - attrs: { - tabs: [ - null, - undefined, - 'string', - 123, - { val: 'center', pos: 1440 }, // Only valid entry - ], - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.tabs).toBeDefined(); - expect(result?.tabs?.length).toBe(1); - expect(result?.tabs?.[0].val).toBe('center'); - }); - }); - - describe('framePr edge cases and validation', () => { - it('should return undefined for empty framePr object', () => { - const para: PMNode = { - attrs: { framePr: {} }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - // Empty framePr should not produce floatAlignment or frame - expect(result?.floatAlignment).toBeUndefined(); - expect(result?.frame).toBeUndefined(); - }); - - it('should handle framePr with attributes wrapper but empty attributes', () => { - const para: PMNode = { - attrs: { framePr: { attributes: {} } }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.floatAlignment).toBeUndefined(); - expect(result?.frame).toBeUndefined(); - }); - - it('should handle non-numeric x/y values gracefully', () => { - const para: PMNode = { - attrs: { - framePr: { - xAlign: 'right', - x: 'invalid', - y: 'bad', - wrap: 'none', - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - // Should extract valid xAlign and wrap, ignore invalid x/y - expect(result?.floatAlignment).toBe('right'); - expect(result?.frame?.xAlign).toBe('right'); - expect(result?.frame?.wrap).toBe('none'); - expect(result?.frame?.x).toBeUndefined(); - expect(result?.frame?.y).toBeUndefined(); - }); - - it('should use w:prefixed keys first via nullish coalescing', () => { - const para: PMNode = { - attrs: { - framePr: { - 'w:xAlign': 'right', - xAlign: 'left', // Should be ignored due to nullish coalescing - 'w:wrap': 'around', - wrap: 'none', // Should be ignored - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - // Should prefer w:prefixed keys - expect(result?.floatAlignment).toBe('right'); - expect(result?.frame?.xAlign).toBe('right'); - expect(result?.frame?.wrap).toBe('around'); - }); - - it('should return undefined frame when all framePr values are invalid', () => { - const para: PMNode = { - attrs: { - framePr: { - xAlign: 'invalid', - yAlign: 'invalid', - x: 'bad', - y: 'bad', - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - // Invalid xAlign should not produce floatAlignment - expect(result?.floatAlignment).toBeUndefined(); - // Frame is still set with invalid xAlign (validation deferred to renderer) - expect(result?.frame?.xAlign).toBe('invalid'); - expect(result?.frame?.yAlign).toBe('invalid'); - // Invalid x and y should not be set - expect(result?.frame?.x).toBeUndefined(); - expect(result?.frame?.y).toBeUndefined(); - }); - - it('should handle mixed valid and invalid framePr properties', () => { - const para: PMNode = { - attrs: { - framePr: { - xAlign: 'center', // valid - yAlign: 'top', // valid - x: 'bad', // invalid - y: 720, // valid - wrap: 'none', // valid - hAnchor: 'margin', // valid - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.floatAlignment).toBe('center'); - expect(result?.frame?.xAlign).toBe('center'); - expect(result?.frame?.yAlign).toBe('top'); - expect(result?.frame?.x).toBeUndefined(); - expect(result?.frame?.y).toBeCloseTo(twipsToPx(720)); - expect(result?.frame?.wrap).toBe('none'); - expect(result?.frame?.hAnchor).toBe('margin'); - }); - - it('should handle framePr with null values', () => { - const para: PMNode = { - attrs: { - framePr: { - xAlign: null, - wrap: null, - y: 1440, - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - // Null values should be ignored by nullish coalescing - expect(result?.floatAlignment).toBeUndefined(); - // Only y should be set - expect(result?.frame?.xAlign).toBeUndefined(); - expect(result?.frame?.wrap).toBeUndefined(); - expect(result?.frame?.y).toBeCloseTo(twipsToPx(1440)); - }); - - it('should handle very large numeric values for x and y', () => { - const para: PMNode = { - attrs: { - framePr: { - xAlign: 'left', - x: Number.MAX_SAFE_INTEGER, - y: Number.MAX_SAFE_INTEGER, - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.floatAlignment).toBe('left'); - // Large values should be converted but remain finite - expect(result?.frame?.x).toBeDefined(); - expect(Number.isFinite(result?.frame?.x)).toBe(true); - expect(result?.frame?.y).toBeDefined(); - expect(Number.isFinite(result?.frame?.y)).toBe(true); - }); - - it('should convert case-insensitive xAlign values correctly', () => { - const testCases = [ - { input: 'LEFT', expected: 'left' }, - { input: 'Right', expected: 'right' }, - { input: 'CENTER', expected: 'center' }, - { input: 'CeNtEr', expected: 'center' }, - ]; - - testCases.forEach(({ input, expected }) => { - const para: PMNode = { - attrs: { - framePr: { xAlign: input }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.floatAlignment).toBe(expected); - expect(result?.frame?.xAlign).toBe(expected); - }); - }); - - it('should set yAlign values without validation', () => { - const para: PMNode = { - attrs: { - framePr: { - xAlign: 'center', - yAlign: 'bottom', - wrap: 'none', - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - // xAlign should still work - expect(result?.floatAlignment).toBe('center'); - expect(result?.frame?.xAlign).toBe('center'); - // yAlign set as-is (no validation at this stage) - expect(result?.frame?.yAlign).toBe('bottom'); - expect(result?.frame?.wrap).toBe('none'); - }); - - it('should handle framePr with only positioning properties (no alignment)', () => { - const para: PMNode = { - attrs: { - framePr: { - x: 1440, - y: 2880, - hAnchor: 'page', - vAnchor: 'page', - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - // No xAlign means no floatAlignment - expect(result?.floatAlignment).toBeUndefined(); - // But frame should still be set with positioning - expect(result?.frame?.x).toBeCloseTo(twipsToPx(1440)); - expect(result?.frame?.y).toBeCloseTo(twipsToPx(2880)); - expect(result?.frame?.hAnchor).toBe('page'); - expect(result?.frame?.vAnchor).toBe('page'); - }); - - it('should handle framePr with dropCap property', () => { - const para: PMNode = { - attrs: { - framePr: { - dropCap: 'drop', - xAlign: 'left', - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.dropCap).toBe('drop'); - expect(result?.floatAlignment).toBe('left'); - }); - - it('should handle w:prefixed dropCap property', () => { - const para: PMNode = { - attrs: { - framePr: { - 'w:dropCap': 'margin', - xAlign: 'center', - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.dropCap).toBe('margin'); - expect(result?.floatAlignment).toBe('center'); - }); - - it('should build dropCapDescriptor with mode and lines from framePr', () => { - const para: PMNode = { - attrs: { - framePr: { - dropCap: 'drop', - lines: 3, - wrap: 'around', - }, - }, - content: [ - { - type: 'text', - text: 'D', - marks: [ - { - type: 'textStyle', - attrs: { - fontSize: '156px', - fontFamily: 'Times New Roman', - }, - }, - ], - }, - ], - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.dropCapDescriptor).toBeDefined(); - expect(result?.dropCapDescriptor?.mode).toBe('drop'); - expect(result?.dropCapDescriptor?.lines).toBe(3); - expect(result?.dropCapDescriptor?.wrap).toBe('around'); - expect(result?.dropCapDescriptor?.run.text).toBe('D'); - expect(result?.dropCapDescriptor?.run.fontFamily).toBe('Times New Roman'); - expect(result?.dropCapDescriptor?.run.fontSize).toBe(156); - }); - - it('should build dropCapDescriptor with margin mode', () => { - const para: PMNode = { - attrs: { - framePr: { - 'w:dropCap': 'margin', - 'w:lines': 2, - }, - }, - content: [ - { - type: 'text', - text: 'W', - }, - ], - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.dropCapDescriptor).toBeDefined(); - expect(result?.dropCapDescriptor?.mode).toBe('margin'); - expect(result?.dropCapDescriptor?.lines).toBe(2); - }); - - it('should extract font styling from nested run nodes', () => { - const para: PMNode = { - attrs: { - framePr: { - dropCap: 'drop', - lines: 4, - }, - }, - content: [ - { - type: 'run', - attrs: { - runProperties: { - fontSize: '117pt', - fontFamily: 'Georgia', - bold: true, - italic: true, - color: '0000FF', - }, - }, - content: [ - { - type: 'text', - text: 'A', - }, - ], - }, - ], - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.dropCapDescriptor).toBeDefined(); - expect(result?.dropCapDescriptor?.run.text).toBe('A'); - expect(result?.dropCapDescriptor?.run.fontFamily).toBe('Georgia'); - expect(result?.dropCapDescriptor?.run.bold).toBe(true); - expect(result?.dropCapDescriptor?.run.italic).toBe(true); - expect(result?.dropCapDescriptor?.run.color).toBe('#0000FF'); - }); - - it('should default to 3 lines when lines not specified', () => { - const para: PMNode = { - attrs: { - framePr: { - dropCap: 'drop', - }, - }, - content: [{ type: 'text', text: 'B' }], - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.dropCapDescriptor?.lines).toBe(3); - }); - - it('should not create dropCapDescriptor without content', () => { - const para: PMNode = { - attrs: { - framePr: { - dropCap: 'drop', - lines: 3, - }, - }, - content: [], - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.dropCapDescriptor).toBeUndefined(); - }); - - it('should normalize wrap value to proper casing', () => { - const para: PMNode = { - attrs: { - framePr: { - dropCap: 'drop', - wrap: 'notBeside', - }, - }, - content: [{ type: 'text', text: 'C' }], - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.dropCapDescriptor?.wrap).toBe('notBeside'); - }); - - it('should handle OOXML half-points font size format', () => { - const para: PMNode = { - attrs: { - framePr: { - dropCap: 'drop', - }, - }, - content: [ - { - type: 'run', - attrs: { - runProperties: { - sz: 234, // Half-points: 234 = 117pt - }, - }, - content: [{ type: 'text', text: 'E' }], - }, - ], - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - // 117pt ≈ 156px (at 96dpi) - expect(result?.dropCapDescriptor?.run.fontSize).toBeCloseTo(156, 0); - }); - }); -}); - -describe('mergeSpacingSources', () => { - describe('priority order', () => { - it('should prioritize attrs over paragraphProps and base', () => { - const base = { before: 10, after: 10, line: 1.0 }; - const paragraphProps = { before: 15, after: 15 }; - const attrs = { before: 20, line: 2.0 }; - - const result = mergeSpacingSources(base, paragraphProps, attrs); - - expect(result).toEqual({ - before: 20, // from attrs (highest priority) - after: 15, // from paragraphProps (middle priority) - line: 2.0, // from attrs - }); - }); - - it('should prioritize paragraphProps over base when attrs is empty', () => { - const base = { before: 10, after: 10, line: 1.0 }; - const paragraphProps = { before: 15, line: 1.5 }; - const attrs = {}; - - const result = mergeSpacingSources(base, paragraphProps, attrs); - - expect(result).toEqual({ - before: 15, // from paragraphProps (overrides base) - after: 10, // from base (not overridden) - line: 1.5, // from paragraphProps (overrides base) - }); - }); - - it('should use base when paragraphProps and attrs are empty', () => { - const base = { before: 10, after: 10, line: 1.0 }; - const paragraphProps = {}; - const attrs = {}; - - const result = mergeSpacingSources(base, paragraphProps, attrs); - - expect(result).toEqual({ - before: 10, - after: 10, - line: 1.0, - }); - }); - - it('should handle correct priority chain: base < paragraphProps < attrs', () => { - const base = { before: 10, after: 10, line: 1.0 }; - const paragraphProps = { before: 15 }; - const attrs = { line: 2.0 }; - - const result = mergeSpacingSources(base, paragraphProps, attrs); - - expect(result).toEqual({ - before: 15, // from paragraphProps (overrides base) - after: 10, // from base (not overridden) - line: 2.0, // from attrs (highest priority) - }); - }); - }); - - describe('partial overrides', () => { - it('should allow partial override from attrs (only line)', () => { - const base = { before: 10, after: 10 }; - const paragraphProps = {}; - const attrs = { line: 1.5 }; - - const result = mergeSpacingSources(base, paragraphProps, attrs); - - expect(result).toEqual({ - before: 10, // inherited from base - after: 10, // inherited from base - line: 1.5, // from attrs - }); - }); - - it('should allow partial override from paragraphProps (only before)', () => { - const base = { before: 10, after: 10, line: 1.0 }; - const paragraphProps = { before: 20 }; - const attrs = {}; - - const result = mergeSpacingSources(base, paragraphProps, attrs); - - expect(result).toEqual({ - before: 20, // from paragraphProps (overrides base) - after: 10, // inherited from base - line: 1.0, // inherited from base - }); - }); - - it('should merge multiple partial overrides correctly', () => { - const base = { before: 10, after: 10, line: 1.0, lineRule: 'auto' }; - const paragraphProps = { before: 20, after: 20 }; - const attrs = { line: 2.0 }; - - const result = mergeSpacingSources(base, paragraphProps, attrs); - - expect(result).toEqual({ - before: 20, // from paragraphProps - after: 20, // from paragraphProps - line: 2.0, // from attrs - lineRule: 'auto', // inherited from base - }); - }); - - it('should handle single property from each source', () => { - const base = { before: 10 }; - const paragraphProps = { after: 20 }; - const attrs = { line: 1.5 }; - - const result = mergeSpacingSources(base, paragraphProps, attrs); - - expect(result).toEqual({ - before: 10, - after: 20, - line: 1.5, - }); - }); - }); - - describe('edge cases', () => { - it('should return undefined when all sources are null', () => { - const result = mergeSpacingSources(null, null, null); - expect(result).toBeUndefined(); - }); - - it('should return undefined when all sources are undefined', () => { - const result = mergeSpacingSources(undefined, undefined, undefined); - expect(result).toBeUndefined(); - }); - - it('should return undefined when all sources are empty objects', () => { - const result = mergeSpacingSources({}, {}, {}); - expect(result).toBeUndefined(); - }); - - it('should handle null base gracefully', () => { - const result = mergeSpacingSources(null, { before: 10 }, { line: 1.5 }); - expect(result).toEqual({ before: 10, line: 1.5 }); - }); - - it('should handle null paragraphProps gracefully', () => { - const result = mergeSpacingSources({ before: 10 }, null, { line: 1.5 }); - expect(result).toEqual({ before: 10, line: 1.5 }); - }); - - it('should handle null attrs gracefully', () => { - const result = mergeSpacingSources({ before: 10 }, { after: 20 }, null); - expect(result).toEqual({ before: 10, after: 20 }); - }); - - it('should handle undefined sources gracefully', () => { - const result = mergeSpacingSources(undefined, { before: 10 }, { line: 1.5 }); - expect(result).toEqual({ before: 10, line: 1.5 }); - }); - - it('should handle non-object values (treat as empty)', () => { - const result = mergeSpacingSources('not an object', { before: 10 }, { line: 1.5 }); - expect(result).toEqual({ before: 10, line: 1.5 }); - }); - - it('should preserve zero values through merge priority', () => { - const base = { before: 10 }; - const paragraphProps = { before: 0 }; // explicit zero overrides base - const attrs = {}; - - const result = mergeSpacingSources(base, paragraphProps, attrs); - expect(result).toEqual({ before: 0 }); - }); - - it('should handle negative values correctly', () => { - const base = { before: 10 }; - const paragraphProps = { after: -5 }; - const attrs = { line: -1.5 }; - - const result = mergeSpacingSources(base, paragraphProps, attrs); - expect(result).toEqual({ - before: 10, - after: -5, - line: -1.5, - }); - }); - }); - - describe('real-world OOXML scenarios', () => { - it('should handle docDefaults + partial style override', () => { - const base = { before: 0, after: 0, line: 1.0, lineRule: 'auto' }; - const paragraphProps = { after: 10 }; - const attrs = {}; - - const result = mergeSpacingSources(base, paragraphProps, attrs); - - expect(result).toEqual({ - before: 0, - after: 10, - line: 1.0, - lineRule: 'auto', - }); - }); - - it('should handle direct paragraph override of only line spacing', () => { - const base = { before: 10, after: 10, line: 1.0 }; - const paragraphProps = {}; - const attrs = { line: 1.5 }; - - const result = mergeSpacingSources(base, paragraphProps, attrs); - - expect(result).toEqual({ - before: 10, - after: 10, - line: 1.5, - }); - }); - - it('should handle three-tier override chain', () => { - const base = { before: 0, after: 0, line: 1.0, lineRule: 'auto' }; - const paragraphProps = { before: 12 }; - const attrs = { after: 8, line: 1.2 }; - - const result = mergeSpacingSources(base, paragraphProps, attrs); - - expect(result).toEqual({ - before: 12, // from paragraphProps - after: 8, // from attrs - line: 1.2, // from attrs - lineRule: 'auto', // from base - }); - }); - - it('should handle complete direct override', () => { - const base = { before: 10, after: 10, line: 1.0, lineRule: 'auto' }; - const paragraphProps = { before: 20, after: 20 }; - const attrs = { before: 5, after: 5, line: 1.5, lineRule: 'exact' }; - - const result = mergeSpacingSources(base, paragraphProps, attrs); - - expect(result).toEqual({ - before: 5, - after: 5, - line: 1.5, - lineRule: 'exact', - }); - }); - }); -}); - -describe('computeParagraphAttrs - alignment priority cascade', () => { - describe('priority order tests', () => { - it('should prioritize explicitAlignment over paragraphAlignment', () => { - const para: PMNode = { - attrs: { - alignment: 'right', - paragraphProperties: { - justification: 'center', - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.alignment).toBe('right'); - }); - - it('should prioritize paragraphAlignment over styleAlignment', () => { - const para: PMNode = { - attrs: { - paragraphProperties: { - justification: 'center', - }, - }, - }; - const styleContext = createTestStyleContext(); - const hydration = { - alignment: 'left', - }; - - const result = computeParagraphAttrs(para, styleContext, undefined, undefined, hydration); - - expect(result?.alignment).toBe('center'); - }); - - it('should prioritize styleAlignment over computed.paragraph.alignment', () => { - const para: PMNode = { - attrs: {}, - }; - const styleContext = createTestStyleContext(); - const hydration = { - alignment: 'right', - }; - - const result = computeParagraphAttrs(para, styleContext, undefined, undefined, hydration); - - expect(result?.alignment).toBe('right'); - }); - - it('should prioritize bidi+adjustRightInd over everything', () => { - const para: PMNode = { - attrs: { - bidi: true, - adjustRightInd: true, - alignment: 'center', - paragraphProperties: { - justification: 'left', - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.alignment).toBe('right'); - }); - }); - - describe('edge case tests', () => { - it('should handle null justification value and fallback to styleAlignment', () => { - const para: PMNode = { - attrs: { - paragraphProperties: { - justification: null, - }, - }, - }; - const styleContext = createTestStyleContext(); - const hydration = { - alignment: 'center', - }; - - const result = computeParagraphAttrs(para, styleContext, undefined, undefined, hydration); - - expect(result?.alignment).toBe('center'); - }); - - it('should handle empty string justification and fallback to styleAlignment', () => { - const para: PMNode = { - attrs: { - paragraphProperties: { - justification: '', - }, - }, - }; - const styleContext = createTestStyleContext(); - const hydration = { - alignment: 'left', - }; - - const result = computeParagraphAttrs(para, styleContext, undefined, undefined, hydration); - - expect(result?.alignment).toBe('left'); - }); - - it('should handle invalid alignment value and fallback to styleAlignment', () => { - const para: PMNode = { - attrs: { - paragraphProperties: { - justification: 'invalid-value', - }, - }, - }; - const styleContext = createTestStyleContext(); - const hydration = { - alignment: 'right', - }; - - const result = computeParagraphAttrs(para, styleContext, undefined, undefined, hydration); - - expect(result?.alignment).toBe('right'); - }); - - it('should handle non-string justification (number) and not crash', () => { - const para: PMNode = { - attrs: { - paragraphProperties: { - justification: 123, - }, - }, - }; - const styleContext = createTestStyleContext(); - const hydration = { - alignment: 'center', - }; - - const result = computeParagraphAttrs(para, styleContext, undefined, undefined, hydration); - - // Should fallback to styleAlignment since number is not a string - expect(result?.alignment).toBe('center'); - }); - }); - - describe('normalization tests', () => { - it('should normalize "both" to "justify"', () => { - const para: PMNode = { - attrs: { - paragraphProperties: { - justification: 'both', - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.alignment).toBe('justify'); - }); - - it('should normalize "start" to "left"', () => { - const para: PMNode = { - attrs: { - paragraphProperties: { - justification: 'start', - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.alignment).toBe('left'); - }); - - it('should normalize "end" to "right"', () => { - const para: PMNode = { - attrs: { - paragraphProperties: { - justification: 'end', - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.alignment).toBe('right'); - }); - }); - - describe('real-world scenario tests', () => { - it('should use center from paragraph props when style has left', () => { - const para: PMNode = { - attrs: { - paragraphProperties: { - justification: 'center', - }, - }, - }; - const styleContext = createTestStyleContext(); - const hydration = { - alignment: 'left', - }; - - const result = computeParagraphAttrs(para, styleContext, undefined, undefined, hydration); - - expect(result?.alignment).toBe('center'); - }); - - it('should use right from explicit when paragraph props has center', () => { - const para: PMNode = { - attrs: { - alignment: 'right', - paragraphProperties: { - justification: 'center', - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.alignment).toBe('right'); - }); - - it('should respect all 6 priority levels in correct order', () => { - // Level 6: computed.paragraph.alignment (lowest) - const para1: PMNode = { attrs: {} }; - const styleContext = createTestStyleContext(); - const result1 = computeParagraphAttrs(para1, styleContext); - // Level 6 provides default 'left' alignment from style-engine when no other sources are present - expect(result1?.alignment).toBe('left'); - - // Level 5: styleAlignment - const para2: PMNode = { attrs: {} }; - const hydration2 = { alignment: 'left' }; - const result2 = computeParagraphAttrs(para2, styleContext, undefined, undefined, hydration2); - expect(result2?.alignment).toBe('left'); - - // Level 4: bidi alone (defaults to right) - const para3: PMNode = { attrs: { bidi: true } }; - const result3 = computeParagraphAttrs(para3, styleContext); - expect(result3?.alignment).toBe('right'); - - // Level 3: paragraphAlignment (overrides bidi default) - const para4: PMNode = { - attrs: { - bidi: true, - paragraphProperties: { justification: 'center' }, - }, - }; - const result4 = computeParagraphAttrs(para4, styleContext); - expect(result4?.alignment).toBe('center'); - - // Level 2: explicitAlignment (overrides paragraphAlignment) - const para5: PMNode = { - attrs: { - alignment: 'justify', - paragraphProperties: { justification: 'center' }, - }, - }; - const result5 = computeParagraphAttrs(para5, styleContext); - expect(result5?.alignment).toBe('justify'); - - // Level 1: bidi + adjustRightInd (overrides everything) - const para6: PMNode = { - attrs: { - bidi: true, - adjustRightInd: true, - alignment: 'justify', - paragraphProperties: { justification: 'center' }, - }, - }; - const result6 = computeParagraphAttrs(para6, styleContext); - expect(result6?.alignment).toBe('right'); - }); - }); -}); - -describe('computeParagraphAttrs - numbering properties fallback from listRendering', () => { - describe('fallback synthesis when numberingProperties is missing', () => { - it('should synthesize numbering props when only listRendering provided', () => { - const para: PMNode = { - attrs: { - listRendering: { - markerText: '1.', - justification: 'left', - numberingType: 'decimal', - suffix: 'tab', - path: [1], - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.numberingProperties).toBeDefined(); - expect(result?.numberingProperties?.numId).toBe(-1); - expect(result?.numberingProperties?.markerText).toBe('1.'); - expect(result?.numberingProperties?.format).toBe('decimal'); - expect(result?.numberingProperties?.lvlJc).toBe('left'); - expect(result?.numberingProperties?.suffix).toBe('tab'); - }); - - it('should correctly extract counter value from path array', () => { - const testCases = [ - { path: [1], expectedCounter: 1 }, - { path: [1, 2], expectedCounter: 2 }, - { path: [1, 2, 3], expectedCounter: 3 }, - { path: [5, 10, 15], expectedCounter: 15 }, - ]; - - testCases.forEach(({ path, expectedCounter }) => { - const para: PMNode = { - attrs: { - listRendering: { - markerText: `${expectedCounter}.`, - path, - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.numberingProperties?.counterValue).toBe(expectedCounter); - expect(result?.numberingProperties?.path).toEqual(path); - }); - }); - - it('should correctly calculate ilvl from path length', () => { - const testCases = [ - { path: [1], expectedIlvl: 0 }, - { path: [1, 2], expectedIlvl: 1 }, - { path: [1, 2, 3], expectedIlvl: 2 }, - { path: [1, 2, 3, 4], expectedIlvl: 3 }, - ]; - - testCases.forEach(({ path, expectedIlvl }) => { - const para: PMNode = { - attrs: { - listRendering: { - markerText: '•', - path, - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.numberingProperties?.ilvl).toBe(expectedIlvl); - }); - }); - - it('should handle empty path array', () => { - const para: PMNode = { - attrs: { - listRendering: { - markerText: '•', - path: [], - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.numberingProperties?.ilvl).toBe(0); - // When path is empty, buildNumberingPath creates [1] and counterValue becomes 1 - expect(result?.numberingProperties?.counterValue).toBe(1); - expect(result?.numberingProperties?.path).toEqual([1]); - }); - - it('should handle missing path array', () => { - const para: PMNode = { - attrs: { - listRendering: { - markerText: '1.', - justification: 'left', - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.numberingProperties?.ilvl).toBe(0); - // When path is undefined, buildNumberingPath creates [1] and counterValue becomes 1 - expect(result?.numberingProperties?.counterValue).toBe(1); - expect(result?.numberingProperties?.path).toEqual([1]); - }); - - it('should preserve original numberingProperties when present', () => { - const para: PMNode = { - attrs: { - numberingProperties: { - numId: 5, - ilvl: 2, - }, - listRendering: { - markerText: 'a)', - path: [1, 2, 3], - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - // Should use original numberingProperties, not synthesize from listRendering - expect(result?.numberingProperties?.numId).toBe(5); - expect(result?.numberingProperties?.ilvl).toBe(2); - }); - - it('should not synthesize when listRendering is missing', () => { - const para: PMNode = { - attrs: {}, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - // Should not create numberingProperties from nothing - expect(result?.numberingProperties).toBeUndefined(); - }); - - it('should synthesize all properties from listRendering', () => { - const para: PMNode = { - attrs: { - listRendering: { - markerText: 'II.', - justification: 'right', - numberingType: 'upperRoman', - suffix: 'space', - path: [1, 2], - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.numberingProperties?.numId).toBe(-1); - expect(result?.numberingProperties?.ilvl).toBe(1); - expect(result?.numberingProperties?.path).toEqual([1, 2]); - expect(result?.numberingProperties?.counterValue).toBe(2); - expect(result?.numberingProperties?.markerText).toBe('II.'); - expect(result?.numberingProperties?.format).toBe('upperRoman'); - expect(result?.numberingProperties?.lvlJc).toBe('right'); - expect(result?.numberingProperties?.suffix).toBe('space'); - }); - - it('should handle single-level list (path with one element)', () => { - const para: PMNode = { - attrs: { - listRendering: { - markerText: '3.', - path: [3], - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.numberingProperties?.ilvl).toBe(0); - expect(result?.numberingProperties?.counterValue).toBe(3); - expect(result?.numberingProperties?.path).toEqual([3]); - }); - - it('should handle bullet list with path', () => { - const para: PMNode = { - attrs: { - listRendering: { - markerText: '•', - justification: 'left', - numberingType: 'bullet', - path: [1, 1], - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.numberingProperties?.format).toBe('bullet'); - expect(result?.numberingProperties?.ilvl).toBe(1); - expect(result?.numberingProperties?.counterValue).toBe(1); - expect(result?.numberingProperties?.markerText).toBe('•'); - }); - - it('should handle non-finite counter values gracefully', () => { - const para: PMNode = { - attrs: { - listRendering: { - markerText: '•', - path: [NaN], - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - // NaN gets filtered out during path normalization, so buildNumberingPath creates [1] - expect(result?.numberingProperties?.counterValue).toBe(1); - }); - - it('should handle deep nesting (path with many levels)', () => { - const para: PMNode = { - attrs: { - listRendering: { - markerText: 'i.', - path: [1, 1, 1, 1, 1], - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.numberingProperties?.ilvl).toBe(4); - expect(result?.numberingProperties?.counterValue).toBe(1); - expect(result?.numberingProperties?.path).toEqual([1, 1, 1, 1, 1]); - }); - - it('should handle partial listRendering (only markerText)', () => { - const para: PMNode = { - attrs: { - listRendering: { - markerText: '-', - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.numberingProperties?.numId).toBe(-1); - expect(result?.numberingProperties?.ilvl).toBe(0); - expect(result?.numberingProperties?.markerText).toBe('-'); - expect(result?.numberingProperties?.format).toBeUndefined(); - expect(result?.numberingProperties?.lvlJc).toBeUndefined(); - expect(result?.numberingProperties?.suffix).toBeUndefined(); - }); - - it('should prioritize paragraphProperties.numberingProperties over fallback', () => { - const para: PMNode = { - attrs: { - paragraphProperties: { - numberingProperties: { - numId: 10, - ilvl: 3, - }, - }, - listRendering: { - markerText: 'Should not be used', - path: [99], - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - // Should use paragraphProperties, not listRendering fallback - expect(result?.numberingProperties?.numId).toBe(10); - expect(result?.numberingProperties?.ilvl).toBe(3); - }); - }); - - describe('contextualSpacing attribute extraction', () => { - const createStyleContext = () => ({ - styles: {}, - defaults: {}, - }); - - describe('fallback chain priority', () => { - it('should prioritize normalizedSpacing.contextualSpacing (priority 1)', () => { - const para: PMNode = { - attrs: { - spacing: { - contextualSpacing: true, - }, - paragraphProperties: { - contextualSpacing: false, // Should be ignored - }, - contextualSpacing: false, // Should be ignored - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.contextualSpacing).toBe(true); - }); - - it('should use paragraphProps.contextualSpacing when normalizedSpacing is absent (priority 2)', () => { - const para: PMNode = { - attrs: { - paragraphProperties: { - contextualSpacing: true, - }, - contextualSpacing: false, // Should be ignored - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.contextualSpacing).toBe(true); - }); - - it('should use attrs.contextualSpacing when both higher priorities are absent (priority 3)', () => { - const para: PMNode = { - attrs: { - contextualSpacing: true, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.contextualSpacing).toBe(true); - }); - - it('should return undefined when contextualSpacing is not set anywhere', () => { - const para: PMNode = { - attrs: { - spacing: { - before: 10, - after: 10, - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.contextualSpacing).toBeUndefined(); - }); - - it('should use hydrated.contextualSpacing when all higher priorities are absent (priority 4)', () => { - const para: PMNode = { - attrs: { - spacing: { - before: 10, - after: 10, - }, - }, - }; - const styleContext = createTestStyleContext(); - const hydrationOverride = { - contextualSpacing: true, - spacing: { before: 10, after: 10 }, - }; - - const result = computeParagraphAttrs(para, styleContext, undefined, undefined, hydrationOverride); - - expect(result?.contextualSpacing).toBe(true); - }); - - it('should use hydrated.contextualSpacing=false when all higher priorities are absent', () => { - const para: PMNode = { - attrs: { - spacing: { - before: 10, - after: 10, - }, - }, - }; - const styleContext = createTestStyleContext(); - const hydrationOverride = { - contextualSpacing: false, - spacing: { before: 10, after: 10 }, - }; - - const result = computeParagraphAttrs(para, styleContext, undefined, undefined, hydrationOverride); - - expect(result?.contextualSpacing).toBe(false); - }); - - it('should prioritize attrs.contextualSpacing over hydrated.contextualSpacing', () => { - const para: PMNode = { - attrs: { - contextualSpacing: true, - spacing: { - before: 10, - after: 10, - }, - }, - }; - const styleContext = createTestStyleContext(); - const hydrationOverride = { - contextualSpacing: false, // Should be overridden by attrs - spacing: { before: 10, after: 10 }, - }; - - const result = computeParagraphAttrs(para, styleContext, undefined, undefined, hydrationOverride); - - expect(result?.contextualSpacing).toBe(true); - }); - - it('should prioritize paragraphProps.contextualSpacing over hydrated.contextualSpacing', () => { - const para: PMNode = { - attrs: { - paragraphProperties: { - contextualSpacing: true, - }, - spacing: { - before: 10, - after: 10, - }, - }, - }; - const styleContext = createTestStyleContext(); - const hydrationOverride = { - contextualSpacing: false, // Should be overridden by paragraphProps - spacing: { before: 10, after: 10 }, - }; - - const result = computeParagraphAttrs(para, styleContext, undefined, undefined, hydrationOverride); - - expect(result?.contextualSpacing).toBe(true); - }); - - it('should prioritize normalizedSpacing.contextualSpacing over hydrated.contextualSpacing', () => { - const para: PMNode = { - attrs: { - spacing: { - before: 10, - after: 10, - contextualSpacing: true, - }, - }, - }; - const styleContext = createTestStyleContext(); - const hydrationOverride = { - contextualSpacing: false, // Should be overridden by spacing.contextualSpacing - spacing: { before: 10, after: 10 }, - }; - - const result = computeParagraphAttrs(para, styleContext, undefined, undefined, hydrationOverride); - - expect(result?.contextualSpacing).toBe(true); - }); - }); - - describe('OOXML boolean value handling', () => { - it('should handle boolean true', () => { - const para: PMNode = { - attrs: { - contextualSpacing: true, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.contextualSpacing).toBe(true); - }); - - it('should handle boolean false', () => { - const para: PMNode = { - attrs: { - contextualSpacing: false, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.contextualSpacing).toBe(false); - }); - - it('should handle numeric 1 as true', () => { - const para: PMNode = { - attrs: { - contextualSpacing: 1, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.contextualSpacing).toBe(true); - }); - - it('should handle numeric 0 as false', () => { - const para: PMNode = { - attrs: { - contextualSpacing: 0, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.contextualSpacing).toBe(false); - }); - - it('should handle string "1" as true', () => { - const para: PMNode = { - attrs: { - contextualSpacing: '1', - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.contextualSpacing).toBe(true); - }); - - it('should handle string "0" as false', () => { - const para: PMNode = { - attrs: { - contextualSpacing: '0', - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.contextualSpacing).toBe(false); - }); - - it('should handle string "true" as true', () => { - const para: PMNode = { - attrs: { - contextualSpacing: 'true', - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.contextualSpacing).toBe(true); - }); - - it('should handle string "false" as false', () => { - const para: PMNode = { - attrs: { - contextualSpacing: 'false', - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.contextualSpacing).toBe(false); - }); - - it('should handle string "on" as true', () => { - const para: PMNode = { - attrs: { - contextualSpacing: 'on', - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.contextualSpacing).toBe(true); - }); - - it('should handle string "off" as false', () => { - const para: PMNode = { - attrs: { - contextualSpacing: 'off', - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.contextualSpacing).toBe(false); - }); - - it('should handle case-insensitive string values', () => { - const para1: PMNode = { - attrs: { - contextualSpacing: 'TRUE', - }, - }; - const para2: PMNode = { - attrs: { - contextualSpacing: 'FALSE', - }, - }; - const para3: PMNode = { - attrs: { - contextualSpacing: 'On', - }, - }; - const styleContext = createTestStyleContext(); - - expect(computeParagraphAttrs(para1, styleContext)?.contextualSpacing).toBe(true); - expect(computeParagraphAttrs(para2, styleContext)?.contextualSpacing).toBe(false); - expect(computeParagraphAttrs(para3, styleContext)?.contextualSpacing).toBe(true); - }); - }); - - describe('edge cases', () => { - it('should treat null as not set', () => { - const para: PMNode = { - attrs: { - contextualSpacing: null, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.contextualSpacing).toBeUndefined(); - }); - - it('should treat undefined as not set', () => { - const para: PMNode = { - attrs: { - contextualSpacing: undefined, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.contextualSpacing).toBeUndefined(); - }); - - it('should handle invalid string values as false', () => { - const para: PMNode = { - attrs: { - contextualSpacing: 'invalid', - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.contextualSpacing).toBe(false); - }); - - it('should handle empty string as false', () => { - const para: PMNode = { - attrs: { - contextualSpacing: '', - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.contextualSpacing).toBe(false); - }); - }); - - describe('integration with spacing', () => { - it('should work together with spacing.before and spacing.after', () => { - const para: PMNode = { - attrs: { - contextualSpacing: true, - spacing: { - before: 10, - after: 20, - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.contextualSpacing).toBe(true); - expect(result?.spacing?.before).toBeDefined(); - expect(result?.spacing?.after).toBeDefined(); - }); - - it('should work when contextualSpacing is in spacing object', () => { - const para: PMNode = { - attrs: { - spacing: { - before: 10, - after: 20, - contextualSpacing: true, - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - expect(result?.contextualSpacing).toBe(true); - expect(result?.spacing?.before).toBeDefined(); - expect(result?.spacing?.after).toBeDefined(); - }); - - it('should integrate with styles that define contextualSpacing (e.g., ListBullet)', () => { - const para: PMNode = { - attrs: { - styleId: 'ListBullet', - spacing: { - before: 10, - after: 10, - }, - }, - }; - const styleContext = createTestStyleContext(); - // Simulate a style like ListBullet that defines contextualSpacing - const hydrationOverride = { - contextualSpacing: true, - spacing: { before: 10, after: 10 }, - }; - - const result = computeParagraphAttrs(para, styleContext, undefined, undefined, hydrationOverride); - - expect(result?.contextualSpacing).toBe(true); - expect(result?.spacing?.before).toBeDefined(); - expect(result?.spacing?.after).toBeDefined(); - }); - - it('should allow paragraph to override style-defined contextualSpacing', () => { - const para: PMNode = { - attrs: { - styleId: 'ListBullet', - contextualSpacing: false, // Explicit override of style - spacing: { - before: 10, - after: 10, - }, - }, - }; - const styleContext = createTestStyleContext(); - // Simulate a style that defines contextualSpacing=true - const hydrationOverride = { - contextualSpacing: true, // From style - spacing: { before: 10, after: 10 }, - }; - - const result = computeParagraphAttrs(para, styleContext, undefined, undefined, hydrationOverride); - - // Paragraph-level override should win - expect(result?.contextualSpacing).toBe(false); - }); - }); - }); -}); - -describe('computeParagraphAttrs - indent priority cascade', () => { - // These tests verify the indent priority order documented in the code: - // 1. hydratedIndentPx - from styles (docDefaults, paragraph styles) - lowest - // 2. paragraphIndentPx - from paragraphProperties.indent (inline paragraph properties) - // 3. textIndentPx - from attrs.textIndent (legacy/alternative format) - // 4. attrsIndentPx - from attrs.indent (direct paragraph attributes - highest priority) - - const createStyleContext = () => - ({ - styles: {}, - defaults: {}, - }) as Parameters[1]; - - describe('priority order: higher-priority source wins for same property', () => { - it('should prioritize attrs.indent over hydrated indent (style)', () => { - const para = { - attrs: { - indent: { left: 48 }, // Direct attribute - highest priority (48px) - }, - }; - const styleContext = createTestStyleContext(); - const hydrationOverride = { - indent: { left: 720 }, // From style in twips (~48px but different) - }; - - const result = computeParagraphAttrs(para, styleContext, undefined, undefined, hydrationOverride); - - // attrs.indent should win over hydrated indent - expect(result?.indent?.left).toBe(48); - }); - - it('should prioritize attrs.indent over paragraphProperties.indent', () => { - const para = { - attrs: { - indent: { left: 24 }, // Direct attribute (24px) - paragraphProperties: { - indent: { left: 1440 }, // Inline property in twips (~96px) - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - // attrs.indent should win over paragraphProperties.indent - expect(result?.indent?.left).toBe(24); - }); - - it('should prioritize attrs.indent over attrs.textIndent', () => { - const para = { - attrs: { - // Use 31 instead of 30 - 30 is divisible by 15 which triggers twips heuristic - indent: { left: 31 }, // Direct attribute (31px) - highest priority - textIndent: { left: 2880 }, // Legacy format in twips (~192px) - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - // attrs.indent should win over textIndent - expect(result?.indent?.left).toBe(31); - }); - - it('should prioritize paragraphProperties.indent over hydrated indent', () => { - const para = { - attrs: { - paragraphProperties: { - indent: { left: 720 }, // Inline property in twips (~48px) - }, - }, - }; - const styleContext = createTestStyleContext(); - const hydrationOverride = { - indent: { left: 1440 }, // From style in twips (~96px) - }; - - const result = computeParagraphAttrs(para, styleContext, undefined, undefined, hydrationOverride); - - // paragraphProperties.indent should win over hydrated indent - expect(result?.indent?.left).toBeCloseTo(twipsToPx(720)); - }); - - it('should use full priority chain: hydrated < paragraphProps < textIndent < attrs.indent', () => { - const para = { - attrs: { - indent: { left: 10, right: 20 }, // Highest priority - textIndent: { left: 1440, hanging: 360 }, // Second highest (~96px left, ~24px hanging) - paragraphProperties: { - indent: { left: 2160, firstLine: 720 }, // Third priority (~144px left, ~48px firstLine) - }, - }, - }; - const styleContext = createTestStyleContext(); - const hydrationOverride = { - indent: { left: 2880, right: 720, firstLine: 360 }, // Lowest priority - }; - - const result = computeParagraphAttrs(para, styleContext, undefined, undefined, hydrationOverride); - - // left: 10 from attrs.indent (highest priority) - expect(result?.indent?.left).toBe(10); - // right: 20 from attrs.indent (highest priority) - expect(result?.indent?.right).toBe(20); - // hanging: from textIndent (next priority with hanging) - // Note: firstLine/hanging mutual exclusivity - when attrs.indent has neither, - // textIndent's hanging applies and should clear paragraphProperties' firstLine - expect(result?.indent?.hanging).toBeCloseTo(twipsToPx(360)); - // firstLine from paragraphProperties is cleared because higher-priority hanging is present - expect(result?.indent?.firstLine).toBeUndefined(); - }); - }); - - describe('zero values as explicit overrides', () => { - // Note: indentPtToPx filters out zero left/right values as they are cosmetic. - // However, zero firstLine/hanging are preserved as they are meaningful overrides. - - it('should filter out zero left/right values (cosmetic optimization)', () => { - // This is documented behavior: zero left/right are filtered out - // because they represent the default state (no indent). - // When explicit zeros are set, they override inherited values in the cascade, - // and then indentPtToPx filters out the zero values from the final result. - const para = { - attrs: { - indent: { left: 0, right: 0 }, // Explicit zeros override hydration - }, - }; - const styleContext = createTestStyleContext(); - const hydrationOverride = { - indent: { left: 720, right: 360 }, // From style in twips - }; - - const result = computeParagraphAttrs(para, styleContext, undefined, undefined, hydrationOverride); - - // Zero left/right are first applied (overriding hydration), then filtered out - // Result: neither left nor right is present (not the hydration values) - expect(result?.indent?.left).toBeUndefined(); - expect(result?.indent?.right).toBeUndefined(); - }); - - it('should preserve zero firstLine as explicit override', () => { - const para = { - attrs: { - indent: { firstLine: 0 }, // Explicit zero override - }, - }; - const styleContext = createTestStyleContext(); - const hydrationOverride = { - indent: { firstLine: 720 }, // From style (~48px) - }; - - const result = computeParagraphAttrs(para, styleContext, undefined, undefined, hydrationOverride); - - // Zero firstLine IS preserved (unlike left/right) - expect(result?.indent?.firstLine).toBe(0); - }); - - it('should preserve zero hanging as explicit override', () => { - const para = { - attrs: { - indent: { hanging: 0 }, // Explicit zero override - }, - }; - const styleContext = createTestStyleContext(); - const hydrationOverride = { - indent: { hanging: 720 }, // From style (~48px) - }; - - const result = computeParagraphAttrs(para, styleContext, undefined, undefined, hydrationOverride); - - // Zero hanging IS preserved (unlike left/right) - expect(result?.indent?.hanging).toBe(0); - }); - - it('should handle firstLine overriding hanging with mutual exclusivity', () => { - // When firstLine is set, hanging should be cleared via firstLine/hanging mutual exclusivity - const para = { - attrs: { - indent: { firstLine: 24 }, // Explicit firstLine - }, - }; - const styleContext = createTestStyleContext(); - const hydrationOverride = { - indent: { hanging: 360 }, // From style (~24px) - }; - - const result = computeParagraphAttrs(para, styleContext, undefined, undefined, hydrationOverride); - - // firstLine should be present - expect(result?.indent?.firstLine).toBe(24); - // hanging from style should be cleared by mutual exclusivity handler - // Per OOXML, firstLine and hanging are mutually exclusive - when firstLine is set, - // hanging should be completely removed (undefined), not just set to 0. - expect(result?.indent?.hanging).toBeUndefined(); - }); - }); - - describe('multiple overlapping indent sources', () => { - it('should merge non-overlapping properties from all sources', () => { - const para = { - attrs: { - indent: { right: 48 }, // Only right - paragraphProperties: { - indent: { left: 720 }, // Only left in twips (~48px) - }, - }, - }; - const styleContext = createTestStyleContext(); - const hydrationOverride = { - indent: { firstLine: 360 }, // Only firstLine in twips (~24px) - }; - - const result = computeParagraphAttrs(para, styleContext, undefined, undefined, hydrationOverride); - - // All properties should be merged from different sources - expect(result?.indent?.right).toBe(48); // from attrs.indent - expect(result?.indent?.left).toBeCloseTo(twipsToPx(720)); // from paragraphProperties - expect(result?.indent?.firstLine).toBeCloseTo(twipsToPx(360)); // from hydration - }); - - it('should handle partial override: left from attrs, rest inherited', () => { - const para = { - attrs: { - indent: { left: 24 }, // Only override left - }, - }; - const styleContext = createTestStyleContext(); - const hydrationOverride = { - indent: { left: 720, right: 360, firstLine: 180 }, // Full indent from style - }; - - const result = computeParagraphAttrs(para, styleContext, undefined, undefined, hydrationOverride); - - // left from attrs.indent overrides hydrated - expect(result?.indent?.left).toBe(24); - // right and firstLine inherited from hydration - expect(result?.indent?.right).toBeCloseTo(twipsToPx(360)); - expect(result?.indent?.firstLine).toBeCloseTo(twipsToPx(180)); - }); - - it('should handle three sources with different properties', () => { - const para = { - attrs: { - indent: { left: 10 }, // Only left - textIndent: { right: 1440 }, // Only right in twips (~96px) - paragraphProperties: { - indent: { firstLine: 360 }, // Only firstLine in twips (~24px) - }, - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - // All properties should be present from their respective sources - expect(result?.indent?.left).toBe(10); - expect(result?.indent?.right).toBeCloseTo(twipsToPx(1440)); - expect(result?.indent?.firstLine).toBeCloseTo(twipsToPx(360)); - }); - }); - - describe('firstLine/hanging mutual exclusivity', () => { - it('should handle firstLine and hanging from different priority sources', () => { - // When a higher-priority source sets firstLine, the combineIndentProperties handler - // processes it, but the actual removal of hanging happens in post-processing. - // The result may still show hanging=0 since indentPtToPx preserves zero values. - const para = { - attrs: { - indent: { firstLine: 24 }, // Higher priority sets firstLine - }, - }; - const styleContext = createTestStyleContext(); - const hydrationOverride = { - indent: { hanging: 360, left: 720 }, // Style has hanging - }; - - const result = computeParagraphAttrs(para, styleContext, undefined, undefined, hydrationOverride); - - // firstLine from attrs should win - expect(result?.indent?.firstLine).toBe(24); - // Per OOXML, firstLine and hanging are mutually exclusive - when firstLine is explicitly set, - // hanging should be completely removed (undefined), not just set to 0. - expect(result?.indent?.hanging).toBeUndefined(); - // left should still be inherited - expect(result?.indent?.left).toBeCloseTo(twipsToPx(720)); - }); - - it('should keep hanging when no higher-priority firstLine exists', () => { - const para = { - attrs: { - indent: { left: 48 }, // Only left, no firstLine - }, - }; - const styleContext = createTestStyleContext(); - const hydrationOverride = { - indent: { hanging: 360 }, // Style has hanging - }; - - const result = computeParagraphAttrs(para, styleContext, undefined, undefined, hydrationOverride); - - // hanging should be preserved since no higher-priority source sets firstLine - expect(result?.indent?.hanging).toBeCloseTo(twipsToPx(360)); - }); - }); - - describe('edge cases', () => { - it('should handle empty indent object from attrs', () => { - const para = { - attrs: { - indent: {}, // Empty indent - }, - }; - const styleContext = createTestStyleContext(); - const hydrationOverride = { - indent: { left: 720 }, // From style - }; - - const result = computeParagraphAttrs(para, styleContext, undefined, undefined, hydrationOverride); - - // Should inherit from hydration since attrs.indent is empty - expect(result?.indent?.left).toBeCloseTo(twipsToPx(720)); - }); - - it('should handle undefined indent gracefully', () => { - const para = { - attrs: { - indent: undefined, - }, - }; - const styleContext = createTestStyleContext(); - const hydrationOverride = { - indent: { left: 720 }, - }; - - const result = computeParagraphAttrs(para, styleContext, undefined, undefined, hydrationOverride); - - expect(result?.indent?.left).toBeCloseTo(twipsToPx(720)); - }); - - it('should handle null indent gracefully', () => { - const para = { - attrs: { - indent: null, - }, - }; - const styleContext = createTestStyleContext(); - const hydrationOverride = { - indent: { left: 720 }, - }; - - const result = computeParagraphAttrs(para, styleContext, undefined, undefined, hydrationOverride); - - expect(result?.indent?.left).toBeCloseTo(twipsToPx(720)); - }); - - it('should handle negative indent values', () => { - const para = { - attrs: { - indent: { left: -20, firstLine: -10 }, // Negative indents (outdents) - }, - }; - const styleContext = createTestStyleContext(); - - const result = computeParagraphAttrs(para, styleContext); - - // Negative values should be preserved - expect(result?.indent?.left).toBe(-20); - expect(result?.indent?.firstLine).toBe(-10); - }); + expect(result.fontFamily).toContain('Arial'); + expect(result.fontSize).toBeGreaterThan(0); + expect(result.color).toBe('#FF0000'); }); }); diff --git a/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts b/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts index 36bf00860b..0d3006b6ed 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts @@ -5,319 +5,34 @@ * including style resolution, boolean attributes, and Word layout integration. */ +import { toCssFontFamily } from '@superdoc/font-utils'; import type { ParagraphAttrs, ParagraphIndent, - ParagraphSpacing, - TabStop, DropCapDescriptor, DropCapRun, + ParagraphFrame, } from '@superdoc/contracts'; -import type { PMNode, StyleNode, StyleContext, ListCounterContext, ListRenderingAttrs } from '../types.js'; -import { resolveStyle, combineIndentProperties } from '@superdoc/style-engine'; -import type { - WordParagraphLayoutOutput, - ResolvedParagraphProperties, - DocDefaults, - NumberingProperties, - ResolvedRunProperties, - ResolvedTabStop, - NumberingFormat, - WordListJustification, - WordListSuffix, -} from '@superdoc/word-layout'; +import type { PMNode } from '../types.js'; +import type { ResolvedRunProperties } from '@superdoc/word-layout'; import { computeWordParagraphLayout } from '@superdoc/word-layout'; -import { Engines } from '@superdoc/contracts'; -import { - pickNumber, - twipsToPx, - isFiniteNumber, - ptToPx, - asOoxmlElement, - findOoxmlChild, - getOoxmlAttribute, - parseOoxmlNumber, - hasOwnProperty, - type OoxmlElement, -} from '../utilities.js'; -import { - normalizeAlignment, - normalizeParagraphSpacing, - normalizeParagraphIndent, - normalizePxIndent, - spacingPxToPt, - indentPxToPt, - spacingPtToPx, - indentPtToPx, -} from './spacing-indent.js'; +import { pickNumber, twipsToPx, isFiniteNumber, ptToPx } from '../utilities.js'; +import { normalizeAlignment, normalizeParagraphSpacing } from './spacing-indent.js'; import { normalizeOoxmlTabs } from './tabs.js'; import { normalizeParagraphBorders, normalizeParagraphShading } from './borders.js'; -import { mirrorIndentForRtl, ensureBidiIndentPx, DEFAULT_BIDI_INDENT_PX } from './bidi.js'; -import { hydrateParagraphStyleAttrs, hydrateMarkerStyleAttrs } from './paragraph-styles.js'; -import type { ParagraphStyleHydration } from './paragraph-styles.js'; -import type { ConverterContext, ConverterNumberingContext } from '../converter-context.js'; +import type { ConverterContext } from '../converter-context.js'; -const { resolveSpacingIndent } = Engines; +import { + resolveParagraphProperties, + resolveRunProperties, + resolveDocxFontFamily, + type ParagraphFrameProperties, + type ParagraphProperties, + type RunProperties, +} from '@superdoc/style-engine/ooxml'; const DEFAULT_DECIMAL_SEPARATOR = '.'; - -/** - * Checks if a numbering ID represents valid numbering properties. - * - * Per OOXML spec §17.9.16, `numId="0"` is a special sentinel value that disables - * numbering inherited from paragraph styles. This function validates that a numId - * is not null/undefined and not the special zero value (either numeric 0 or string '0'). - * - * @param numId - The numbering ID to validate (can be number, string, null, or undefined) - * @returns true if numId represents valid numbering (not null/undefined/0/'0'), false otherwise - * - * @example - * ```typescript - * isValidNumberingId(1) // true - valid numbering - * isValidNumberingId('5') // true - valid numbering (string form) - * isValidNumberingId(0) // false - disables numbering (OOXML spec) - * isValidNumberingId('0') // false - disables numbering (string form) - * isValidNumberingId(null) // false - no numbering - * isValidNumberingId(undefined) // false - no numbering - * ``` - */ -export const isValidNumberingId = (numId: number | string | null | undefined): boolean => { - return numId != null && numId !== 0 && numId !== '0'; -}; - -/** - * Tracks which paragraph spacing properties were explicitly set. - * - * Used to distinguish between explicit spacing values and those inherited - * from docDefaults/styles, which affects empty paragraph rendering behavior. - */ -type SpacingExplicit = { - /** Whether 'before' spacing was explicitly set */ - before?: boolean; - /** Whether 'after' spacing was explicitly set */ - after?: boolean; - /** Whether 'line' spacing was explicitly set */ - line?: boolean; -}; - -/** - * Extracts which spacing properties are explicitly set from a plain object. - * - * Checks for the presence of spacing-related property keys to determine - * if spacing values were explicitly specified vs inherited. - * - * @param value - The spacing object to analyze - * @returns Object indicating which spacing properties are explicit - * - * @example - * ```typescript - * extractSpacingExplicitFromObject({ before: 240 }); // { before: true } - * extractSpacingExplicitFromObject({ line: 360 }); // { line: true } - * extractSpacingExplicitFromObject({}); // {} - * ``` - */ -const extractSpacingExplicitFromObject = (value: unknown): SpacingExplicit => { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - const obj = value as Record; - const explicit: SpacingExplicit = {}; - if ( - hasOwnProperty(obj, 'before') || - hasOwnProperty(obj, 'lineSpaceBefore') || - hasOwnProperty(obj, 'beforeAutospacing') || - hasOwnProperty(obj, 'beforeAutoSpacing') - ) { - explicit.before = true; - } - if ( - hasOwnProperty(obj, 'after') || - hasOwnProperty(obj, 'lineSpaceAfter') || - hasOwnProperty(obj, 'afterAutospacing') || - hasOwnProperty(obj, 'afterAutoSpacing') - ) { - explicit.after = true; - } - if (hasOwnProperty(obj, 'line') || hasOwnProperty(obj, 'lineRule')) { - explicit.line = true; - } - return explicit; -}; - -/** - * Extracts which spacing properties are explicitly set from OOXML paragraph properties. - * - * Parses the OOXML structure to find w:spacing element attributes and determines - * which spacing values were explicitly specified in the document. - * - * @param value - The OOXML paragraph properties element - * @returns Object indicating which spacing properties are explicit - * - * @example - * ```typescript - * // For XML: - * extractSpacingExplicitFromOoxml(pPrElement); // { before: true } - * ``` - */ -const extractSpacingExplicitFromOoxml = (value: unknown): SpacingExplicit => { - const element = asOoxmlElement(value); - if (!element) return {}; - const pPr = element.name === 'w:pPr' ? element : findOoxmlChild(element, 'w:pPr'); - const spacingEl = findOoxmlChild(pPr, 'w:spacing'); - if (!spacingEl) return {}; - const explicit: SpacingExplicit = {}; - if ( - getOoxmlAttribute(spacingEl, 'w:before') != null || - getOoxmlAttribute(spacingEl, 'w:beforeAutospacing') != null || - getOoxmlAttribute(spacingEl, 'w:beforeAutoSpacing') != null - ) { - explicit.before = true; - } - if ( - getOoxmlAttribute(spacingEl, 'w:after') != null || - getOoxmlAttribute(spacingEl, 'w:afterAutospacing') != null || - getOoxmlAttribute(spacingEl, 'w:afterAutoSpacing') != null - ) { - explicit.after = true; - } - if (getOoxmlAttribute(spacingEl, 'w:line') != null || getOoxmlAttribute(spacingEl, 'w:lineRule') != null) { - explicit.line = true; - } - return explicit; -}; - -/** - * Merges multiple SpacingExplicit objects into one. - * - * If any source has a property set to true, the result will have that property as true. - * This allows tracking explicit settings across multiple sources (paragraphProps, attrs, OOXML). - * - * @param sources - The SpacingExplicit objects to merge - * @returns Merged SpacingExplicit with all explicit flags combined - * - * @example - * ```typescript - * mergeSpacingExplicit({ before: true }, { after: true }); - * // { before: true, after: true } - * ``` - */ -const mergeSpacingExplicit = (...sources: SpacingExplicit[]): SpacingExplicit => { - const merged: SpacingExplicit = {}; - for (const source of sources) { - if (source.before) merged.before = true; - if (source.after) merged.after = true; - if (source.line) merged.line = true; - } - return merged; -}; - -/** - * Merges spacing from multiple sources with increasing priority. - * - * In OOXML, a paragraph can have partial spacing overrides (e.g., only `line`) - * while inheriting other properties (e.g., `before`, `after`) from docDefaults - * or styles. This function merges all sources so that explicit values override - * defaults, but missing values fall back to lower-priority sources. - * - * Priority (lowest to highest): base (docDefaults/styles) < paragraphProps < attrs - * - * @param base - Spacing from hydrated styles (includes docDefaults) - * @param paragraphProps - Spacing from paragraphProperties - * @param attrs - Spacing from direct paragraph attrs (highest priority) - * @returns Merged spacing object, or undefined if all sources are empty - * - * @example - * ```typescript - * // Partial override: attrs only specifies 'line', inherits 'before' and 'after' from base - * mergeSpacingSources( - * { before: 10, after: 10 }, - * {}, - * { line: 1.5 } - * ) - * // Returns: { before: 10, after: 10, line: 1.5 } - * - * // Full override: attrs overrides all properties from base - * mergeSpacingSources( - * { before: 10, after: 10, line: 1.0 }, - * {}, - * { before: 20, after: 20, line: 2.0 } - * ) - * // Returns: { before: 20, after: 20, line: 2.0 } - * - * // Empty sources: returns undefined - * mergeSpacingSources({}, {}, {}) - * // Returns: undefined - * ``` - */ -export const mergeSpacingSources = ( - base: unknown, - paragraphProps: unknown, - attrs: unknown, -): Record | undefined => { - const isObject = (v: unknown): v is Record => v !== null && typeof v === 'object'; - - const baseObj = isObject(base) ? base : {}; - const propsObj = isObject(paragraphProps) ? paragraphProps : {}; - const attrsObj = isObject(attrs) ? attrs : {}; - - // If none of the sources have any data, return undefined - if (Object.keys(baseObj).length === 0 && Object.keys(propsObj).length === 0 && Object.keys(attrsObj).length === 0) { - return undefined; - } - - // Merge with increasing priority: base < paragraphProps < attrs - return { ...baseObj, ...propsObj, ...attrsObj }; -}; - -const normalizeNumFmt = (value?: unknown): NumberingFormat | undefined => { - if (typeof value !== 'string') return undefined; - switch (value) { - case 'decimal': - return 'decimal'; - case 'lowerLetter': - return 'lowerLetter'; - case 'upperLetter': - return 'upperLetter'; - case 'lowerRoman': - return 'lowerRoman'; - case 'upperRoman': - return 'upperRoman'; - case 'bullet': - return 'bullet'; - default: - return undefined; - } -}; - -const normalizeSuffix = (value?: unknown): WordListSuffix => { - if (typeof value !== 'string') return undefined; - if (value === 'tab' || value === 'space' || value === 'nothing') { - return value; - } - return undefined; -}; - -const normalizeJustification = (value?: unknown): WordListJustification | undefined => { - if (typeof value !== 'string') return undefined; - if (value === 'start') return 'left'; - if (value === 'end') return 'right'; - if (value === 'left' || value === 'center' || value === 'right') return value; - return undefined; -}; - -const extractIndentFromLevel = (lvl: OoxmlElement | undefined): ParagraphIndent | undefined => { - const pPr = findOoxmlChild(lvl, 'w:pPr'); - const ind = findOoxmlChild(pPr, 'w:ind'); - if (!ind) return undefined; - const left = parseOoxmlNumber(getOoxmlAttribute(ind, 'w:left')); - const right = parseOoxmlNumber(getOoxmlAttribute(ind, 'w:right')); - const firstLine = parseOoxmlNumber(getOoxmlAttribute(ind, 'w:firstLine')); - const hanging = parseOoxmlNumber(getOoxmlAttribute(ind, 'w:hanging')); - const indent: ParagraphIndent = {}; - if (left != null) indent.left = left; - if (right != null) indent.right = right; - if (firstLine != null) indent.firstLine = firstLine; - if (hanging != null) indent.hanging = hanging; - return Object.keys(indent).length ? indent : undefined; -}; +const DEFAULT_TAB_INTERVAL_TWIPS = 720; // 0.5 inch const normalizeColor = (value?: unknown): string | undefined => { if (typeof value !== 'string') return undefined; @@ -327,371 +42,26 @@ const normalizeColor = (value?: unknown): string | undefined => { return `#${upper.toUpperCase()}`; }; -const extractMarkerRun = (lvl: OoxmlElement | undefined): ResolvedRunProperties | undefined => { - const rPr = findOoxmlChild(lvl, 'w:rPr'); - if (!rPr) return undefined; - - const run: Partial = {}; - const rFonts = findOoxmlChild(rPr, 'w:rFonts'); - const font = - getOoxmlAttribute(rFonts, 'w:ascii') ?? - getOoxmlAttribute(rFonts, 'w:hAnsi') ?? - getOoxmlAttribute(rFonts, 'w:eastAsia'); - if (typeof font === 'string' && font.trim()) { - run.fontFamily = font; - } - - const sz = - parseOoxmlNumber(getOoxmlAttribute(findOoxmlChild(rPr, 'w:sz'), 'w:val')) ?? - parseOoxmlNumber(getOoxmlAttribute(findOoxmlChild(rPr, 'w:szCs'), 'w:val')); - if (sz != null) { - run.fontSize = sz / 2; // w:sz is in half-points - } - - const color = normalizeColor(getOoxmlAttribute(findOoxmlChild(rPr, 'w:color'), 'w:val')); - if (color) run.color = color; - - const boldEl = findOoxmlChild(rPr, 'w:b'); - if (boldEl) { - const boldVal = getOoxmlAttribute(boldEl, 'w:val'); - if (boldVal == null || isTruthy(boldVal)) run.bold = true; - } - - const italicEl = findOoxmlChild(rPr, 'w:i'); - if (italicEl) { - const italicVal = getOoxmlAttribute(italicEl, 'w:val'); - if (italicVal == null || isTruthy(italicVal)) run.italic = true; - } - - const spacingTwips = parseOoxmlNumber(getOoxmlAttribute(findOoxmlChild(rPr, 'w:spacing'), 'w:val')); - if (spacingTwips != null && Number.isFinite(spacingTwips)) { - run.letterSpacing = twipsToPx(spacingTwips); - } - - return Object.keys(run).length ? (run as ResolvedRunProperties) : undefined; -}; - -const findNumFmtElement = (lvl: OoxmlElement | undefined): OoxmlElement | undefined => { - if (!lvl) return undefined; - const direct = findOoxmlChild(lvl, 'w:numFmt'); - if (direct) return direct; - const alternate = findOoxmlChild(lvl, 'mc:AlternateContent'); - const choice = findOoxmlChild(alternate, 'mc:Choice'); - if (choice) { - return findOoxmlChild(choice, 'w:numFmt'); - } - return undefined; -}; - -const resolveNumberingFromContext = ( - numId: string | number, - ilvl: number, - numbering?: ConverterNumberingContext, -): Partial | undefined => { - const definitions = numbering?.definitions as Record | undefined; - const abstracts = numbering?.abstracts as Record | undefined; - if (!definitions || !abstracts) { - return undefined; - } - - const numDef = asOoxmlElement(definitions[String(numId)]); - if (!numDef) { - return undefined; - } - - const abstractId = getOoxmlAttribute(findOoxmlChild(numDef, 'w:abstractNumId'), 'w:val'); - if (abstractId == null) { - return undefined; - } - - const abstract = asOoxmlElement(abstracts[String(abstractId)]); - if (!abstract) { - return undefined; - } - - let levelDef = abstract.elements?.find( - (el) => el?.name === 'w:lvl' && parseOoxmlNumber(el.attributes?.['w:ilvl']) === ilvl, - ); - - const override = numDef.elements?.find( - (el) => el?.name === 'w:lvlOverride' && parseOoxmlNumber(el.attributes?.['w:ilvl']) === ilvl, - ); - const overrideLvl = findOoxmlChild(override, 'w:lvl'); - if (overrideLvl) { - levelDef = overrideLvl; - } - const startOverride = parseOoxmlNumber(getOoxmlAttribute(findOoxmlChild(override, 'w:startOverride'), 'w:val')); - - if (!levelDef) { - return undefined; - } - - const numFmtEl = findNumFmtElement(levelDef); - const lvlText = getOoxmlAttribute(findOoxmlChild(levelDef, 'w:lvlText'), 'w:val') as string | undefined; - const start = startOverride ?? parseOoxmlNumber(getOoxmlAttribute(findOoxmlChild(levelDef, 'w:start'), 'w:val')); - const suffix = normalizeSuffix(getOoxmlAttribute(findOoxmlChild(levelDef, 'w:suff'), 'w:val')); - const lvlJc = normalizeJustification(getOoxmlAttribute(findOoxmlChild(levelDef, 'w:lvlJc'), 'w:val')); - const indent = extractIndentFromLevel(levelDef); - const markerRun = extractMarkerRun(levelDef); - - const numFmt = normalizeNumFmt(getOoxmlAttribute(numFmtEl, 'w:val')); - - return { - format: numFmt, - lvlText, - start, - suffix, - lvlJc, - resolvedLevelIndent: indent, - resolvedMarkerRpr: markerRun, - }; -}; - -/** - * Check if a value represents a truthy boolean. - */ -const isTruthy = (value: unknown): boolean => { - if (value === true || value === 1) return true; - if (typeof value === 'string') { - const normalized = value.toLowerCase(); - if (normalized === 'true' || normalized === '1' || normalized === 'on') { - return true; - } - } - return false; -}; - -/** - * Safely extracts a property from an unknown object. - * Used to replace unsafe type assertions with proper type guards. - * - * @param obj - The object to extract from - * @param key - The property key to extract - * @returns The property value, or undefined if not accessible - */ -const safeGetProperty = (obj: unknown, key: string): unknown => { - if (!obj || typeof obj !== 'object') { - return undefined; +export const deepClone = (obj: T): T => { + if (obj === null || typeof obj !== 'object') { + return obj; } - const record = obj as Record; - return record[key]; -}; - -/** - * Check if a value represents an explicit false boolean. - */ -const isExplicitFalse = (value: unknown): boolean => { - if (value === false || value === 0) return true; - if (typeof value === 'string') { - const normalized = value.toLowerCase(); - return normalized === 'false' || normalized === '0' || normalized === 'off'; + if (Array.isArray(obj)) { + return obj.map((item) => deepClone(item)) as unknown as T; } - return false; -}; - -/** - * Infer boolean value from OOXML paragraph elements. - */ -const inferBooleanFromParagraphElements = ( - paragraphProps: Record, - elementNames: string | string[], -): boolean | undefined => { - const elements = (paragraphProps as { elements?: unknown }).elements; - if (!Array.isArray(elements)) return undefined; - - const normalizedTargets = new Set( - (Array.isArray(elementNames) ? elementNames : [elementNames]).flatMap((name) => - name.startsWith('w:') ? [name, name.slice(2)] : [name, `w:${name}`], - ), - ); - - const match = elements.find((el): el is { name: string; attributes?: Record } => { - if (!el || typeof el !== 'object') return false; - const name = (el as { name?: unknown }).name; - return typeof name === 'string' && normalizedTargets.has(name); - }); - - if (!match) return undefined; - - const rawVal = match.attributes?.['w:val'] ?? match.attributes?.val; - - if (rawVal == null) return true; - if (isExplicitFalse(rawVal)) return false; - if (isTruthy(rawVal)) return true; - return undefined; -}; - -/** - * Resolve a boolean attribute from paragraph node, checking both direct attrs and paragraphProperties. - */ -export const resolveParagraphBooleanAttr = (para: PMNode, key: string, elementName: string): boolean | undefined => { - const attrs = (para.attrs ?? {}) as Record; - if (key in attrs) { - const direct = attrs[key]; - if (isTruthy(direct)) return true; - if (isExplicitFalse(direct)) return false; - } - const paragraphProps = attrs.paragraphProperties as Record | undefined; - if (!paragraphProps) return undefined; - if (key in paragraphProps) { - const nested = (paragraphProps as Record)[key]; - if (isTruthy(nested)) return true; - if (isExplicitFalse(nested)) return false; - } - return inferBooleanFromParagraphElements(paragraphProps, elementName); -}; - -/** - * Check if paragraph has page break before it. - */ -export const hasPageBreakBefore = (para: PMNode): boolean => { - const attrs = (para.attrs ?? {}) as Record; - if (isTruthy(attrs.pageBreakBefore)) { - return true; - } - const paragraphProps = attrs.paragraphProperties as Record | undefined; - if (paragraphProps && isTruthy(paragraphProps.pageBreakBefore)) { - return true; - } - if (paragraphProps) { - const inferred = inferBooleanFromParagraphElements(paragraphProps, 'w:pageBreakBefore'); - if (typeof inferred === 'boolean') { - return inferred; + const clone: Record = {}; + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + clone[key] = deepClone((obj as Record)[key]); } } - return false; -}; - -/** - * Clone paragraph attributes deeply. - */ -export const cloneParagraphAttrs = (attrs?: ParagraphAttrs): ParagraphAttrs | undefined => { - if (!attrs) return undefined; - const clone: ParagraphAttrs = { ...attrs }; - if (attrs.spacing) clone.spacing = { ...attrs.spacing }; - if (attrs.spacingExplicit) clone.spacingExplicit = { ...attrs.spacingExplicit }; - if (attrs.indent) clone.indent = { ...attrs.indent }; - if (attrs.borders) { - const borderClone: ParagraphAttrs['borders'] = {}; - (['top', 'right', 'bottom', 'left'] as const).forEach((side) => { - const border = attrs.borders?.[side]; - if (border) { - borderClone[side] = { ...border }; - } - }); - clone.borders = Object.keys(borderClone).length ? borderClone : undefined; - } - if (attrs.shading) clone.shading = { ...attrs.shading }; - if (attrs.tabs) clone.tabs = attrs.tabs.map((tab) => ({ ...tab })); - // Clone drop cap descriptor deeply - if (attrs.dropCapDescriptor) { - clone.dropCapDescriptor = { - ...attrs.dropCapDescriptor, - run: { ...attrs.dropCapDescriptor.run }, - }; - } - return clone; -}; - -/** - * Build a style node from paragraph node attributes. - * Used for style resolution with the style engine. - */ -export const buildStyleNodeFromAttrs = ( - attrs: Record | undefined, - spacing?: ParagraphSpacing, - indent?: ParagraphIndent, -): StyleNode => { - if (!attrs) return {}; - - const paragraphProps: StyleNode['paragraphProps'] = {}; - - const alignment = normalizeAlignment(attrs.alignment ?? attrs.textAlign); - if (alignment) { - paragraphProps.alignment = alignment; - } - - if (spacing) { - paragraphProps.spacing = spacingPxToPt(spacing); - } - - if (indent) { - paragraphProps.indent = indentPxToPt(indent); - } - - const rawTabs = (attrs.tabs ?? attrs.tabStops) as unknown; - const tabs = normalizeOoxmlTabs(rawTabs); - if (tabs) { - paragraphProps.tabs = tabs; - } - - const styleNode: StyleNode = {}; - if (paragraphProps && Object.keys(paragraphProps).length > 0) { - styleNode.paragraphProps = paragraphProps; - } - - return styleNode; -}; - -/** - * Normalize list rendering attributes from raw attributes. - */ -export const normalizeListRenderingAttrs = (value: unknown): ListRenderingAttrs | undefined => { - if (!value || typeof value !== 'object') return undefined; - const source = value as Record; - - const markerText = typeof source.markerText === 'string' ? source.markerText : undefined; - const justification = - source.justification === 'left' || source.justification === 'right' || source.justification === 'center' - ? source.justification - : undefined; - const numberingType = typeof source.numberingType === 'string' ? source.numberingType : undefined; - const suffix = - source.suffix === 'tab' || source.suffix === 'space' || source.suffix === 'nothing' ? source.suffix : undefined; - - const path = - Array.isArray(source.path) && source.path.length - ? (source.path - .map((entry) => (typeof entry === 'number' ? entry : Number(entry))) - .filter((entry) => Number.isFinite(entry)) as number[]) - : undefined; - - return { - markerText, - justification, - numberingType, - suffix, - path: path && path.length ? path : undefined, - }; -}; - -/** - * Build numbering path for multi-level lists (e.g., "1.2.3"). - */ -export const buildNumberingPath = ( - numId: number | undefined, - ilvl: number, - counterValue: number, - listCounterContext?: ListCounterContext, -): number[] => { - const targetLevel = Number.isFinite(ilvl) && ilvl > 0 ? Math.floor(ilvl) : 0; - if (!listCounterContext || typeof numId !== 'number') { - return Array.from({ length: targetLevel + 1 }, (_, level) => (level === targetLevel ? counterValue : 1)); - } - - const path: number[] = []; - for (let level = 0; level < targetLevel; level += 1) { - const parentValue = listCounterContext.getListCounter(numId, level); - path.push(parentValue > 0 ? parentValue : 1); - } - path.push(counterValue); - return path; + return clone as T; }; /** * Convert indent from twips to pixels. */ -const convertIndentTwipsToPx = (indent?: ParagraphIndent | null): ParagraphIndent | undefined => { +const normalizeIndentTwipsToPx = (indent?: ParagraphIndent | null): ParagraphIndent | undefined => { if (!indent) return undefined; const result: ParagraphIndent = {}; const toNum = (v: unknown): number | undefined => { @@ -712,64 +82,75 @@ const convertIndentTwipsToPx = (indent?: ParagraphIndent | null): ParagraphInden return Object.keys(result).length > 0 ? result : undefined; }; -type AdapterNumberingProps = (NumberingProperties & { - path?: number[]; - counterValue?: number; - resolvedLevelIndent?: ParagraphIndent; - resolvedMarkerRpr?: ResolvedRunProperties; -}) & - Record; +export const normalizeFramePr = (value: ParagraphFrameProperties | undefined): ParagraphFrame | undefined => { + if (!value) return undefined; -const toAdapterNumberingProps = (value: unknown): AdapterNumberingProps | undefined => { - if (!value || typeof value !== 'object') return undefined; - const candidate = value as Record; - const rawNumId = candidate.numId; - if (typeof rawNumId !== 'number' && typeof rawNumId !== 'string') { + const frame: Record = {}; + if (value.wrap) { + frame.wrap = value.wrap; + } + if (value.x != null) { + frame.x = twipsToPx(value.x); + } + if (value.y != null) { + frame.y = twipsToPx(value.y); + } + if (value.xAlign) { + frame.xAlign = value.xAlign as 'left' | 'right' | 'center'; + } + if (value.yAlign) { + frame.yAlign = value.yAlign as 'top' | 'center' | 'bottom'; + } + if (value.hAnchor) { + frame.hAnchor = value.hAnchor; + } + if (value.vAnchor) { + frame.vAnchor = value.vAnchor; + } + return Object.keys(frame).length > 0 ? (frame as ParagraphFrame) : undefined; +}; + +export const normalizeNumberingProperties = ( + value: ParagraphProperties['numberingProperties'] | undefined, +): ParagraphProperties['numberingProperties'] | undefined => { + if (value?.numId === 0) { return undefined; } - const rawIlvl = candidate.ilvl; - const normalizedIlvl = Number.isFinite(rawIlvl) ? Math.floor(Number(rawIlvl)) : 0; - return { - ...(candidate as Record), - numId: rawNumId, - ilvl: normalizedIlvl, - } as AdapterNumberingProps; + return value; }; +export const normalizeDropCap = ( + framePr: ParagraphFrameProperties | undefined, + para: PMNode, + converterContext?: ConverterContext, +): DropCapDescriptor | undefined => { + if (!framePr || !framePr.dropCap || framePr.dropCap === 'none') return undefined; + + const dropCap = framePr.dropCap; -const toResolvedTabStops = (tabs?: TabStop[] | null): ResolvedTabStop[] | undefined => { - if (!Array.isArray(tabs) || tabs.length === 0) return undefined; - const resolved: ResolvedTabStop[] = []; + // Build structured DropCapDescriptor for enhanced drop cap support + const dropCapMode = typeof dropCap === 'string' ? dropCap.toLowerCase() : 'drop'; + const linesValue = pickNumber(framePr.lines); - for (const stop of tabs) { - if (!stop || typeof stop.pos !== 'number') continue; - const alignment = normalizeResolvedTabAlignment(stop.val); - if (!alignment) continue; - const position = twipsToPx(stop.pos); - if (!Number.isFinite(position)) continue; + // Extract the drop cap text and run styling from paragraph content + const dropCapRunInfo = extractDropCapRunFromParagraph(para, converterContext); - const resolvedStop: ResolvedTabStop = { - position, - alignment, + if (dropCapRunInfo) { + const descriptor: DropCapDescriptor = { + mode: dropCapMode === 'margin' ? 'margin' : 'drop', + lines: linesValue != null && linesValue > 0 ? linesValue : 3, + run: dropCapRunInfo, }; - if (stop.leader && stop.leader !== 'none') { - resolvedStop.leader = stop.leader as ResolvedTabStop['leader']; - } - resolved.push(resolvedStop); - } - return resolved.length > 0 ? resolved : undefined; -}; + // Map wrap value to the expected types + if (framePr.wrap) { + descriptor.wrap = (framePr.wrap === 'auto' ? undefined : framePr.wrap) as + | 'around' + | 'notBeside' + | 'none' + | 'tight'; + } -const normalizeResolvedTabAlignment = (value: TabStop['val']): ResolvedTabStop['alignment'] | undefined => { - switch (value) { - case 'start': - case 'center': - case 'end': - case 'decimal': - case 'bar': - return value; - default: - return undefined; + return descriptor; } }; @@ -794,1213 +175,168 @@ const DEFAULT_DROP_CAP_FONT_FAMILY = 'Times New Roman'; * @param para - The paragraph PM node to extract drop cap info from * @returns DropCapRun with text and styling, or null if extraction fails */ -const extractDropCapRunFromParagraph = (para: PMNode): DropCapRun | null => { +const extractDropCapRunFromParagraph = (para: PMNode, converterContext?: ConverterContext): DropCapRun | null => { const content = para.content; if (!Array.isArray(content) || content.length === 0) { return null; } - // Find the first text content in the paragraph - let text = ''; - let runProperties: Record = {}; - let textStyleMarks: Record = {}; - - /** - * Maximum recursion depth for extractTextAndStyle to prevent stack overflow. - * A depth of 50 should be sufficient for any reasonable document structure. - */ - const MAX_RECURSION_DEPTH = 50; - - const extractTextAndStyle = (nodes: PMNode[], depth = 0): boolean => { - // Guard against excessive recursion depth - if (depth > MAX_RECURSION_DEPTH) { - console.warn(`extractTextAndStyle exceeded max recursion depth (${MAX_RECURSION_DEPTH})`); - return false; - } - - for (const node of nodes) { - if (!node) continue; - - // Check for text node - if (node.type === 'text' && typeof node.text === 'string' && node.text.length > 0) { - text = node.text; - // Extract styling from marks - if (Array.isArray(node.marks)) { - for (const mark of node.marks) { - if (mark?.type === 'textStyle' && mark.attrs) { - textStyleMarks = { ...textStyleMarks, ...(mark.attrs as Record) }; - } - } - } - return true; - } - - // Check for run node that may contain text - if (node.type === 'run') { - // Extract run properties - if (node.attrs?.runProperties && typeof node.attrs.runProperties === 'object') { - runProperties = { ...runProperties, ...(node.attrs.runProperties as Record) }; - } - // Also check for marks on the run node - if (Array.isArray(node.marks)) { - for (const mark of node.marks) { - if (mark?.type === 'textStyle' && mark.attrs) { - textStyleMarks = { ...textStyleMarks, ...(mark.attrs as Record) }; - } - } - } - // Look for text in run's children with incremented depth - if (Array.isArray(node.content) && extractTextAndStyle(node.content, depth + 1)) { - return true; - } - } - - // Look for text in other container nodes with incremented depth - if (Array.isArray(node.content) && extractTextAndStyle(node.content, depth + 1)) { - return true; - } - } - return false; - }; - - extractTextAndStyle(content); - - // If no text found, cannot create a drop cap run - if (!text) { + const firstRun = content.find((node) => node?.type === 'run'); + if (!firstRun || !Array.isArray(firstRun.content)) { return null; } - - // Merge run properties and text style marks to get final styling - const mergedStyle = { ...runProperties, ...textStyleMarks }; - - // Parse font size - can be in various formats: '117pt', '48px', number, etc. - let fontSizePx = DEFAULT_DROP_CAP_FONT_SIZE_PX; - const rawFontSize = mergedStyle.fontSize ?? mergedStyle['w:sz'] ?? mergedStyle.sz; - if (rawFontSize != null) { - if (typeof rawFontSize === 'number') { - // If number > 100, assume it's half-points (Word uses half-points for sz) - // Half-points: w:sz=234 means 117pt - const converted = rawFontSize > 100 ? ptToPx(rawFontSize / 2) : rawFontSize; - fontSizePx = converted ?? DEFAULT_DROP_CAP_FONT_SIZE_PX; - } else if (typeof rawFontSize === 'string') { - const numericPart = parseFloat(rawFontSize); - if (Number.isFinite(numericPart)) { - if (rawFontSize.endsWith('pt')) { - const converted = ptToPx(numericPart); - fontSizePx = converted ?? DEFAULT_DROP_CAP_FONT_SIZE_PX; - } else if (rawFontSize.endsWith('px')) { - // px values are already in pixels - fontSizePx = numericPart; - } else { - // Plain number string - assume half-points if large - const converted = numericPart > 100 ? ptToPx(numericPart / 2) : numericPart; - fontSizePx = converted ?? DEFAULT_DROP_CAP_FONT_SIZE_PX; - } - } - } + const textNode = firstRun.content.find( + (node) => node?.type === 'text' && typeof node.text === 'string' && node.text.length > 0, + ); + if (!textNode || !textNode.text) { + return null; } - // Parse font family - let fontFamily = DEFAULT_DROP_CAP_FONT_FAMILY; - const rawFontFamily = mergedStyle.fontFamily ?? mergedStyle['w:rFonts'] ?? mergedStyle.rFonts; - if (typeof rawFontFamily === 'string') { - fontFamily = rawFontFamily; - } else if (rawFontFamily && typeof rawFontFamily === 'object') { - // rFonts can be an object with ascii, hAnsi, etc. - const rFonts = rawFontFamily as Record; - const ascii = rFonts['w:ascii'] ?? rFonts.ascii; - if (typeof ascii === 'string') { - fontFamily = ascii; - } + const text = textNode.text; + const runProperties = (firstRun.attrs?.runProperties ?? {}) as RunProperties; + let resolvedRunProperties; + if (converterContext) { + resolvedRunProperties = resolveRunProperties(converterContext, runProperties, {}, null, false, false); + } else { + resolvedRunProperties = runProperties as RunProperties; } + const runAttrs = computeRunAttrs( + resolvedRunProperties, + converterContext, + DEFAULT_DROP_CAP_FONT_SIZE_PX, + DEFAULT_DROP_CAP_FONT_FAMILY, + ); + // Build the drop cap run const dropCapRun: DropCapRun = { text, - fontFamily, - fontSize: fontSizePx, + fontFamily: runAttrs.fontFamily, + fontSize: runAttrs.fontSize, + bold: runAttrs.bold, + italic: runAttrs.italic, + color: runAttrs.color, + position: resolvedRunProperties.position != null ? ptToPx(resolvedRunProperties.position / 2) : undefined, }; - // Parse optional properties - const bold = mergedStyle.bold ?? mergedStyle['w:b'] ?? mergedStyle.b; - if (isTruthy(bold)) { - dropCapRun.bold = true; - } - - const italic = mergedStyle.italic ?? mergedStyle['w:i'] ?? mergedStyle.i; - if (isTruthy(italic)) { - dropCapRun.italic = true; - } - - const color = mergedStyle.color ?? mergedStyle['w:color'] ?? mergedStyle.val; - if (typeof color === 'string' && color.length > 0 && color.toLowerCase() !== 'auto') { - // Ensure color has # prefix if it's a hex color - dropCapRun.color = color.startsWith('#') ? color : `#${color}`; - } - - // Parse vertical position offset (from w:position, in half-points, can be negative) - const position = mergedStyle.position ?? mergedStyle['w:position']; - if (position != null) { - const posNum = pickNumber(position); - if (posNum != null) { - // Convert half-points to pixels - dropCapRun.position = ptToPx(posNum / 2); - } - } - return dropCapRun; }; -/** - * Compute Word paragraph layout for numbered paragraphs. - * - * Integrates with @superdoc/word-layout to compute accurate list marker positioning, - * indent calculation, and marker text rendering. Merges paragraph indent with - * level-specific indent from numbering definitions. - * - * @param paragraphAttrs - Resolved paragraph attributes including spacing, indent, and tabs - * @param numberingProps - Numbering properties with numId, ilvl, and optional resolved marker RPr - * @param styleContext - Style context for resolving character styles and doc defaults - * @param paragraphNode - Optional paragraph node used to hydrate marker run properties via OOXML cascade - * @returns WordParagraphLayoutOutput with marker and gutter information, or null if computation fails - * - * @remarks - * - Returns null early if numberingProps is explicitly null (vs undefined) - * - Uses marker hydration when converterContext is available, then falls back to resolvedMarkerRpr and style-engine defaults - * - Converts indent from twips to pixels for rendering - * - Gracefully handles computation errors by returning null - */ -export const computeWordLayoutForParagraph = ( - paragraphAttrs: ParagraphAttrs, - numberingProps: AdapterNumberingProps | undefined, - styleContext: StyleContext, - paragraphNode?: PMNode, - converterContext?: ConverterContext, - resolvedPpr?: Record, -): WordParagraphLayoutOutput | null => { - if (numberingProps === null) { - return null; - } - - try { - // Merge paragraph indent with level-specific indent from numbering definition. - // Numbering level provides base indent, but paragraph/style can override specific properties. - // For example, a style may set firstLine=0 to remove numbering's firstLine indent. - let effectiveIndent = paragraphAttrs.indent; - - if (numberingProps?.resolvedLevelIndent) { - const resolvedIndentPx = convertIndentTwipsToPx(numberingProps.resolvedLevelIndent as ParagraphIndent); - const numberingIndent = resolvedIndentPx ?? (numberingProps.resolvedLevelIndent as ParagraphIndent); - - // Numbering indent is the base, paragraph/style indent overrides - effectiveIndent = { - ...numberingIndent, - ...paragraphAttrs.indent, - }; - } - - const resolvedTabs = toResolvedTabStops(paragraphAttrs.tabs); - - // Build resolved paragraph properties - const resolvedParagraph: ResolvedParagraphProperties = { - indent: effectiveIndent, - spacing: paragraphAttrs.spacing, - tabs: resolvedTabs, - tabIntervalTwips: paragraphAttrs.tabIntervalTwips, - alignment: paragraphAttrs.alignment as 'left' | 'center' | 'right' | 'justify' | undefined, - decimalSeparator: paragraphAttrs.decimalSeparator, - numberingProperties: numberingProps, - }; - - // Build doc defaults from style context - const defaultFontFamily = - styleContext.defaults?.paragraphFont ?? styleContext.defaults?.paragraphFontFamily ?? 'Times New Roman'; - const defaultFontSize = styleContext.defaults?.fontSize ?? 12; - - const docDefaults: DocDefaults = { - defaultTabIntervalTwips: styleContext.defaults?.defaultTabIntervalTwips ?? 720, - decimalSeparator: styleContext.defaults?.decimalSeparator ?? '.', - run: { - fontFamily: defaultFontFamily, - fontSize: defaultFontSize, - }, - paragraph: { - indent: {}, - spacing: {}, - }, - }; - - let markerRun: ResolvedRunProperties | undefined; - - const markerHydration = - paragraphNode && converterContext ? hydrateMarkerStyleAttrs(paragraphNode, converterContext, resolvedPpr) : null; - - if (markerHydration) { - const resolvedColor = markerHydration.color ? `#${markerHydration.color.replace('#', '')}` : undefined; - markerRun = { - fontFamily: markerHydration.fontFamily ?? 'Times New Roman', - fontSize: markerHydration.fontSize / 2, // half-points to points - bold: markerHydration.bold, - italic: markerHydration.italic, - color: resolvedColor, - letterSpacing: markerHydration.letterSpacing != null ? twipsToPx(markerHydration.letterSpacing) : undefined, - }; - } - - if (!markerRun) { - markerRun = numberingProps?.resolvedMarkerRpr; - } - - if (!markerRun) { - // Fallback to style-engine when converterContext is not available - // This path uses hardcoded defaults but maintains backwards compatibility - const { character: characterStyle } = resolveStyle({ styleId: paragraphAttrs.styleId }, styleContext); - if (characterStyle) { - markerRun = { - fontFamily: characterStyle.font?.family ?? 'Times New Roman', - fontSize: characterStyle.font?.size ?? 12, - bold: characterStyle.font?.weight != null && characterStyle.font.weight > 400, - italic: characterStyle.font?.italic, - color: characterStyle.color, - letterSpacing: characterStyle.letterSpacing, - }; - } - } - - // Final fallback if neither hydration nor style-engine returned anything - if (!markerRun) { - markerRun = { - fontFamily: 'Times New Roman', - fontSize: 12, - color: '#000000', - }; - } - - // Convert marker fontSize from points to pixels - // Style-engine and document defaults use points, but buildFontCss expects pixels - if (markerRun.fontSize != null) { - const fontSizePx = ptToPx(markerRun.fontSize); - if (fontSizePx != null) { - markerRun = { ...markerRun, fontSize: fontSizePx }; - } - } - - // Compute Word paragraph layout - const layout = computeWordParagraphLayout({ - paragraph: resolvedParagraph, - numbering: numberingProps, - markerRun, - docDefaults, - }); - - return layout; - } catch { - // Graceful fallback if wordLayout computation fails - return null; - } -}; - -const normalizeWordLayoutForIndent = ( - wordLayout: WordParagraphLayoutOutput, - paragraphIndent: ParagraphIndent | undefined, -): WordParagraphLayoutOutput => { - const resolvedIndent = wordLayout.resolvedIndent ?? paragraphIndent ?? {}; - const indentLeft = isFiniteNumber(resolvedIndent.left) ? resolvedIndent.left : 0; - const firstLine = isFiniteNumber(resolvedIndent.firstLine) ? resolvedIndent.firstLine : 0; - const hanging = isFiniteNumber(resolvedIndent.hanging) ? resolvedIndent.hanging : 0; - const shouldFirstLineIndentMode = firstLine > 0 && !hanging; - - if (wordLayout.firstLineIndentMode === true && !shouldFirstLineIndentMode) { - wordLayout.firstLineIndentMode = false; - } - - if (wordLayout.firstLineIndentMode === true) { - if (isFiniteNumber(wordLayout.textStartPx)) { - if ( - wordLayout.marker && - (!isFiniteNumber(wordLayout.marker.textStartX) || wordLayout.marker.textStartX !== wordLayout.textStartPx) - ) { - wordLayout.marker.textStartX = wordLayout.textStartPx; - } - } else if (wordLayout.marker && isFiniteNumber(wordLayout.marker.textStartX)) { - wordLayout.textStartPx = wordLayout.marker.textStartX; - } - } else { - wordLayout.textStartPx = indentLeft; - if (wordLayout.marker) { - wordLayout.marker.textStartX = indentLeft; - } - } - - return wordLayout; -}; - /** * Compute paragraph attributes from PM node, resolving styles and handling BiDi text. * This is the main function for converting PM paragraph attributes to layout engine format. */ export const computeParagraphAttrs = ( para: PMNode, - styleContext: StyleContext, - listCounterContext?: ListCounterContext, converterContext?: ConverterContext, - hydrationOverride?: ParagraphStyleHydration | null, -): ParagraphAttrs | undefined => { +): { paragraphAttrs: ParagraphAttrs; resolvedParagraphProperties: ParagraphProperties } => { const attrs = para.attrs ?? {}; - const paragraphProps = - typeof attrs.paragraphProperties === 'object' && attrs.paragraphProperties !== null - ? (attrs.paragraphProperties as Record) - : {}; - const hydrated = hydrationOverride ?? hydrateParagraphStyleAttrs(para, converterContext); - // Merge spacing from all sources: hydrated (docDefaults/styles) < paragraphProps < attrs - // This ensures that a partial spacing override (e.g., only line) doesn't discard - // defaults for unspecified fields (e.g., before/after from docDefaults). - const mergedSpacing = mergeSpacingSources(hydrated?.spacing, paragraphProps.spacing, attrs.spacing); - const normalizedSpacing = normalizeParagraphSpacing(mergedSpacing); - const spacingExplicit = mergeSpacingExplicit( - extractSpacingExplicitFromObject(paragraphProps.spacing), - extractSpacingExplicitFromObject(attrs.spacing), - extractSpacingExplicitFromOoxml(paragraphProps), - ); - const normalizeIndentObject = (value: unknown): ParagraphIndent | undefined => { - if (!value || typeof value !== 'object') return; - return normalizePxIndent(value) ?? convertIndentTwipsToPx(value); - }; - /** - * Build indent chain with increasing priority order (lowest to highest): - * 1. hydratedIndentPx - from styles (docDefaults, paragraph styles) - * 2. paragraphIndentPx - from paragraphProperties.indent (inline paragraph properties) - * 3. textIndentPx - from attrs.textIndent (legacy/alternative format) - * 4. attrsIndentPx - from attrs.indent (direct paragraph attributes - highest priority) - * - * This follows the standard OOXML cascade: styles < inline properties < direct attributes. - * The `combineIndentProperties` function merges these in order, where later entries - * override earlier ones for the same property. - */ - const hydratedIndentPx = convertIndentTwipsToPx(hydrated?.indent as ParagraphIndent); - const paragraphIndentPx = convertIndentTwipsToPx(paragraphProps.indent as ParagraphIndent); - const textIndentPx = normalizeParagraphIndent(attrs.textIndent); - const attrsIndentPx = normalizeIndentObject(attrs.indent); - - const indentChain: Array> = []; - if (hydratedIndentPx) indentChain.push({ indent: hydratedIndentPx }); - if (paragraphIndentPx) indentChain.push({ indent: paragraphIndentPx }); - if (textIndentPx) indentChain.push({ indent: textIndentPx }); - if (attrsIndentPx) indentChain.push({ indent: attrsIndentPx }); - - const normalizedIndent = indentChain.length - ? (combineIndentProperties(indentChain).indent as ParagraphIndent | undefined) - : undefined; - - /** - * Unwraps and normalizes tab stop data structures from various formats. - * - * Handles two primary formats: - * 1. Nested format: `{ tab: { tabType: 'start', pos: 720 } }` (OOXML-style) - * 2. Direct format: `{ val: 'start', pos: 720 }` (normalized) - * - * Performs runtime validation to ensure: - * - Input is an array - * - Each entry is an object with valid structure - * - Required properties (val/tabType and pos) are present and correctly typed - * - Optional properties (leader, originalPos) are validated if present - * - * @param tabStops - Unknown input that may contain tab stop data - * @returns Array of normalized tab stop objects, or undefined if invalid/empty - * - * @example - * ```typescript - * // Nested format - * unwrapTabStops([{ tab: { tabType: 'start', pos: 720 } }]) - * // Returns: [{ val: 'start', pos: 720 }] - * - * // Direct format - * unwrapTabStops([{ val: 'center', pos: 1440, leader: 'dot' }]) - * // Returns: [{ val: 'center', pos: 1440, leader: 'dot' }] - * - * // Invalid input - * unwrapTabStops("not an array") - * // Returns: undefined - * ``` - */ - const unwrapTabStops = (tabStops: unknown): Array> | undefined => { - // Runtime type guard: validate input is an array - if (!Array.isArray(tabStops)) { - return undefined; - } - - const unwrapped: Array> = []; - - for (const entry of tabStops) { - // Runtime type guard: validate entry is a non-null object - if (!entry || typeof entry !== 'object') { - continue; - } - - // Type guard: check for nested format { tab: {...} } - if ('tab' in entry) { - const entryRecord = entry as Record; - const tab = entryRecord.tab; - - // Validate tab property is a non-null object - if (!tab || typeof tab !== 'object') { - continue; - } - - const tabObj = tab as Record; - - // Validate and extract val (alignment type) - const val = - typeof tabObj.tabType === 'string' ? tabObj.tabType : typeof tabObj.val === 'string' ? tabObj.val : undefined; - - // Validate and extract pos (position in twips) - // Priority: originalPos > pos. If originalPos is absent, preserve pos as both pos and originalPos - // so downstream normalization (which doesn't know about nesting) keeps twips and skips px heuristics. - const originalPos = pickNumber(tabObj.originalPos); - const pos = originalPos ?? pickNumber(tabObj.pos); - - // Skip entry if required fields are missing or invalid - if (!val || pos == null) { - continue; - } - - // Build normalized tab stop object with validated properties - const normalized: Record = { val, pos }; - - // Set originalPos when available; if absent, mirror pos to preserve twips through later flattening - if (originalPos != null && Number.isFinite(originalPos)) { - normalized.originalPos = originalPos; - } else { - normalized.originalPos = pos; - } - - // Validate and add optional leader property - const leader = tabObj.leader; - if (typeof leader === 'string' && leader.length > 0) { - normalized.leader = leader; - } - - unwrapped.push(normalized); - continue; - } - - // Direct format - entry is already a tab stop object - // Validate it has the expected structure before adding - const entryRecord = entry as Record; - - // Check if it has at least the basic tab stop properties - const hasValidStructure = - ('val' in entryRecord || 'tabType' in entryRecord) && ('pos' in entryRecord || 'originalPos' in entryRecord); - - if (hasValidStructure) { - unwrapped.push(entryRecord); - } - } - - return unwrapped.length > 0 ? unwrapped : undefined; - }; - - const styleNodeAttrs = { ...attrs }; - const asTabStopArray = (value: unknown): Array> | undefined => { - return Array.isArray(value) ? (value as Array>) : undefined; - }; - const attrTabStops = - unwrapTabStops(styleNodeAttrs.tabStops ?? styleNodeAttrs.tabs) ?? asTabStopArray(styleNodeAttrs.tabStops); - const hydratedTabStops = unwrapTabStops(hydrated?.tabStops) ?? asTabStopArray(hydrated?.tabStops); - const paragraphTabStops = unwrapTabStops(paragraphProps.tabStops) ?? asTabStopArray(paragraphProps.tabStops); - - // Keep the unit heuristic aligned with normalizeOoxmlTabs. - const TAB_STOP_PX_TO_TWIPS = 15; - const TAB_STOP_TWIPS_THRESHOLD = 1000; - - const getTabStopPosition = (entry: Record): number | undefined => { - const originalPos = pickNumber(entry.originalPos); - if (originalPos != null) return originalPos; - const posValue = pickNumber(entry.pos ?? entry.position ?? entry.offset); - if (posValue == null) return undefined; - return posValue > TAB_STOP_TWIPS_THRESHOLD ? posValue : Math.round(posValue * TAB_STOP_PX_TO_TWIPS); - }; - - const mergeTabStopSources = ( - ...sources: Array> | undefined> - ): Array> | undefined => { - const merged = new Map>(); - for (const source of sources) { - if (!Array.isArray(source)) continue; - for (const stop of source) { - if (!stop || typeof stop !== 'object') continue; - const position = getTabStopPosition(stop); - if (position == null) continue; - merged.set(position, { ...stop }); - } - } - if (merged.size === 0) return undefined; - return Array.from(merged.entries()) - .sort(([a], [b]) => a - b) - .map(([, stop]) => stop); - }; - - const mergedTabStops = mergeTabStopSources(hydratedTabStops, paragraphTabStops, attrTabStops); - - if (mergedTabStops) { - styleNodeAttrs.tabStops = mergedTabStops; - if ('tabs' in styleNodeAttrs) { - delete styleNodeAttrs.tabs; - } - } - - const styleNode = buildStyleNodeFromAttrs(styleNodeAttrs, normalizedSpacing, normalizedIndent); - if (styleNodeAttrs.styleId == null && paragraphProps.styleId) { - styleNode.styleId = paragraphProps.styleId as string; - } - const computed = resolveStyle(styleNode, styleContext); - const { spacing, indent } = resolveSpacingIndent(computed.paragraph, computed.numbering); - - const paragraphAttrs: ParagraphAttrs = {}; - const bidi = resolveParagraphBooleanAttr(para, 'bidi', 'w:bidi') === true; - const adjustRightInd = resolveParagraphBooleanAttr(para, 'adjustRightInd', 'w:adjustRightInd') === true; - - if (bidi) { - paragraphAttrs.direction = 'rtl'; - paragraphAttrs.rtl = true; - } - - /** - * Paragraph alignment priority cascade (6 levels, highest to lowest): - * - * 1. bidi + adjustRightInd: Forced right alignment for BiDi paragraphs with right indent adjustment - * 2. explicitAlignment: Direct alignment attribute on the paragraph node (attrs.alignment or attrs.textAlign) - * 3. paragraphAlignment: Paragraph justification from paragraphProperties (inline paragraph-level formatting) - * 4. bidi alone: Default right alignment for BiDi paragraphs without explicit alignment - * 5. styleAlignment: Alignment from hydrated paragraph style (style-based formatting) - * 6. computed.paragraph.alignment: Fallback alignment from style engine computation - * - * This cascade ensures that inline paragraph properties (level 3) correctly override style-based - * alignment (levels 5-6), matching Microsoft Word's behavior where direct paragraph formatting - * takes precedence over style-based formatting. - */ - const explicitAlignment = normalizeAlignment(attrs.alignment ?? attrs.textAlign); - const paragraphAlignment = - typeof paragraphProps.justification === 'string' ? normalizeAlignment(paragraphProps.justification) : undefined; - const styleAlignment = hydrated?.alignment ? normalizeAlignment(hydrated.alignment) : undefined; - - if (bidi && adjustRightInd) { - paragraphAttrs.alignment = 'right'; - } else if (explicitAlignment) { - paragraphAttrs.alignment = explicitAlignment; - } else if (paragraphAlignment) { - // Inline paragraph justification should override style-derived alignment - paragraphAttrs.alignment = paragraphAlignment; - } else if (bidi) { - // RTL paragraphs without explicit alignment default to right - paragraphAttrs.alignment = 'right'; - } else if (styleAlignment) { - paragraphAttrs.alignment = styleAlignment; - } else if (computed.paragraph.alignment) { - paragraphAttrs.alignment = normalizeAlignment(computed.paragraph.alignment); - } - - const spacingPx = spacingPtToPx(spacing, normalizedSpacing); - if (spacingPx) paragraphAttrs.spacing = spacingPx; - if (normalizedSpacing?.beforeAutospacing != null || normalizedSpacing?.afterAutospacing != null) { - paragraphAttrs.spacing = paragraphAttrs.spacing ?? {}; - if (normalizedSpacing?.beforeAutospacing != null) { - (paragraphAttrs.spacing as Record).beforeAutospacing = normalizedSpacing.beforeAutospacing; - } - if (normalizedSpacing?.afterAutospacing != null) { - (paragraphAttrs.spacing as Record).afterAutospacing = normalizedSpacing.afterAutospacing; - } - } - paragraphAttrs.spacingExplicit = spacingExplicit; - /** - * Extract contextualSpacing from multiple sources with fallback chain. - * - * OOXML stores contextualSpacing (w:contextualSpacing) as a sibling to spacing (w:spacing), - * not nested within it. However, our normalization may place it in different locations. - * - * Fallback priority (highest to lowest): - * 1. normalizedSpacing.contextualSpacing - Value from normalized spacing object - * 2. paragraphProps.contextualSpacing - Direct property on paragraphProperties - * 3. attrs.contextualSpacing - Top-level attribute - * 4. hydrated.contextualSpacing - Value resolved from paragraph style chain - * - * The hydrated fallback (priority 4) is critical for style-defined contextualSpacing, - * such as the ListBullet style which defines w:contextualSpacing to suppress spacing - * between consecutive list items of the same style ("Don't add space between paragraphs - * of the same style" in MS Word). - * - * OOXML Boolean Handling: - * - Supports multiple representations: true, 1, '1', 'true', 'on' - * - Uses isTruthy() to handle all valid OOXML boolean forms - * - Treats null/undefined as "not set" (no contextualSpacing) - */ - const contextualSpacingValue = - normalizedSpacing?.contextualSpacing ?? - safeGetProperty(paragraphProps, 'contextualSpacing') ?? - safeGetProperty(attrs, 'contextualSpacing') ?? - hydrated?.contextualSpacing; - - if (contextualSpacingValue != null) { - // Use isTruthy to properly handle OOXML boolean representations (true, 1, '1', 'true', 'on') - paragraphAttrs.contextualSpacing = isTruthy(contextualSpacingValue); - } - - const hasExplicitIndent = Boolean(normalizedIndent); - const hasNumberingIndent = Boolean(computed.numbering?.indent?.left || computed.numbering?.indent?.hanging); - if (hasExplicitIndent || hasNumberingIndent || (bidi && adjustRightInd)) { - const styleIndentPx = indentPtToPx(indent); - - if (styleIndentPx || normalizedIndent) { - // Merge style-resolved indent with explicitly set indent (normalizedIndent has priority). - // The normalizedIndent already has firstLine/hanging mutual exclusivity handled by combineIndentProperties. - const mergedIndent: ParagraphIndent = { ...(styleIndentPx ?? {}) }; - - // Apply explicit indent values, which override style defaults - if (normalizedIndent) { - // Copy explicit values from normalizedIndent - // Note: Zero left/right are treated as "no indent" and filtered out (cosmetic optimization), - // but zero firstLine/hanging are meaningful overrides and must be preserved. - if (normalizedIndent.left !== undefined) { - if (normalizedIndent.left === 0) { - delete mergedIndent.left; // Zero left is cosmetic, filter it out - } else { - mergedIndent.left = normalizedIndent.left; - } - } - if (normalizedIndent.right !== undefined) { - if (normalizedIndent.right === 0) { - delete mergedIndent.right; // Zero right is cosmetic, filter it out - } else { - mergedIndent.right = normalizedIndent.right; - } - } - if (normalizedIndent.firstLine !== undefined) mergedIndent.firstLine = normalizedIndent.firstLine; - if (normalizedIndent.hanging !== undefined) mergedIndent.hanging = normalizedIndent.hanging; - - // Handle firstLine/hanging mutual exclusivity: if normalizedIndent has hanging (and thus - // firstLine was deleted by combineIndentProperties), remove firstLine from merged result. - // Similarly, if normalizedIndent has firstLine, remove hanging. - if (normalizedIndent.hanging !== undefined && normalizedIndent.firstLine === undefined) { - delete mergedIndent.firstLine; - } else if (normalizedIndent.firstLine !== undefined && normalizedIndent.hanging === undefined) { - delete mergedIndent.hanging; - } - } - - const adjustedIndent = bidi && adjustRightInd ? ensureBidiIndentPx({ ...mergedIndent }) : mergedIndent; - const finalIndent = bidi && adjustRightInd ? mirrorIndentForRtl({ ...adjustedIndent }) : adjustedIndent; - paragraphAttrs.indent = finalIndent; - } else if (bidi && adjustRightInd) { - const syntheticIndent: ParagraphIndent = { left: DEFAULT_BIDI_INDENT_PX, right: DEFAULT_BIDI_INDENT_PX }; - const finalIndent = mirrorIndentForRtl({ ...syntheticIndent }); - paragraphAttrs.indent = finalIndent; - } - } - - const borders = normalizeParagraphBorders(attrs.borders ?? hydrated?.borders ?? paragraphProps.borders); - if (borders) paragraphAttrs.borders = borders; - - const shading = normalizeParagraphShading(attrs.shading ?? hydrated?.shading ?? paragraphProps.shading); - if (shading) paragraphAttrs.shading = shading; - - const keepNext = paragraphProps.keepNext ?? hydrated?.keepNext ?? attrs.keepNext; - if (keepNext === true) paragraphAttrs.keepNext = true; - const keepLines = paragraphProps.keepLines ?? hydrated?.keepLines ?? attrs.keepLines; - if (keepLines === true) paragraphAttrs.keepLines = true; - - const paragraphDecimalSeparator = styleContext.defaults?.decimalSeparator ?? DEFAULT_DECIMAL_SEPARATOR; - if (paragraphDecimalSeparator !== DEFAULT_DECIMAL_SEPARATOR) { - paragraphAttrs.decimalSeparator = paragraphDecimalSeparator; - } - const styleIdAttr = typeof attrs.styleId === 'string' ? attrs.styleId : undefined; - if (styleIdAttr) { - paragraphAttrs.styleId = styleIdAttr; - } else if (paragraphProps.styleId) { - paragraphAttrs.styleId = paragraphProps.styleId as string; - } - - // Per‑paragraph tab interval override (px or twips) - const paraIntervalTwips = - pickNumber(attrs.tabIntervalTwips) ?? - ((): number | undefined => { - const px = pickNumber(attrs.tabIntervalPx); - return px != null ? Math.round(px * 15) : undefined; - })(); - const defaultIntervalTwips = styleContext.defaults?.defaultTabIntervalTwips; - if (paraIntervalTwips != null) { - paragraphAttrs.tabIntervalTwips = paraIntervalTwips; - } else if (defaultIntervalTwips != null) { - paragraphAttrs.tabIntervalTwips = defaultIntervalTwips; - } - - if (computed.paragraph.tabs && computed.paragraph.tabs.length > 0) { - paragraphAttrs.tabs = computed.paragraph.tabs.map((tab) => ({ ...tab })); - } else if (mergedTabStops) { - const normalizedTabs = normalizeOoxmlTabs(mergedTabStops as unknown); - if (normalizedTabs) { - paragraphAttrs.tabs = normalizedTabs; - } - } else if (hydratedTabStops) { - const normalizedTabs = normalizeOoxmlTabs(hydratedTabStops as unknown); - if (normalizedTabs) { - paragraphAttrs.tabs = normalizedTabs; - } + const paragraphProperties = (attrs.paragraphProperties ?? {}) as ParagraphProperties; + let resolvedParagraphProperties; + if (!converterContext) { + resolvedParagraphProperties = paragraphProperties; + } else { + resolvedParagraphProperties = resolveParagraphProperties( + converterContext, + paragraphProperties, + converterContext.tableInfo, + ); } - /** - * Safely converts an unknown value to a string. - * - * @param value - The value to convert - * @returns The value as a string if it is a string, otherwise undefined - */ - const asString = (value: unknown): string | undefined => { - return typeof value === 'string' ? value : undefined; - }; - - /** - * Normalizes framePr data from various input formats to a consistent object structure. - * - * OOXML framePr (w:framePr) defines paragraph positioning and floating text alignment, - * commonly used for positioned elements like page numbers in headers/footers. - * - * This function handles three different input structures: - * 1. Direct object with frame properties: `{ xAlign: 'right', yAlign: 'top', ... }` - * 2. Wrapped in attributes object: `{ attributes: { xAlign: 'right', ... } }` - * 3. Invalid/missing data: returns undefined - * - * @param value - The framePr value from OOXML parsing, which may be in various formats - * @returns A record containing the frame properties, or undefined if invalid - * - * @example - * // Direct object - * normalizeFramePr({ xAlign: 'right', yAlign: 'top' }) - * // => { xAlign: 'right', yAlign: 'top' } - * - * @example - * // Wrapped in attributes - * normalizeFramePr({ attributes: { xAlign: 'right' } }) - * // => { xAlign: 'right' } - * - * @example - * // Invalid input - * normalizeFramePr(null) - * // => undefined - */ - const normalizeFramePr = (value: unknown): Record | undefined => { - if (!value || typeof value !== 'object') return undefined; - const record = value as Record; - if (record.attributes && typeof record.attributes === 'object') { - return record.attributes as Record; - } - return record; + const normalizedSpacing = normalizeParagraphSpacing( + resolvedParagraphProperties.spacing, + Boolean(resolvedParagraphProperties.numberingProperties), + ); + const normalizedIndent = normalizeIndentTwipsToPx(resolvedParagraphProperties.indent as ParagraphIndent); + const normalizedTabStops = normalizeOoxmlTabs(resolvedParagraphProperties.tabStops); + const normalizedAlignment = normalizeAlignment(resolvedParagraphProperties.justification); + const normalizedBorders = normalizeParagraphBorders(resolvedParagraphProperties.borders); + const normalizedShading = normalizeParagraphShading(resolvedParagraphProperties.shading); + const paragraphDecimalSeparator = DEFAULT_DECIMAL_SEPARATOR; + const tabIntervalTwips = DEFAULT_TAB_INTERVAL_TWIPS; + const normalizedFramePr = normalizeFramePr(resolvedParagraphProperties.framePr); + const floatAlignment = normalizedFramePr?.xAlign; + const normalizedNumberingProperties = normalizeNumberingProperties(resolvedParagraphProperties.numberingProperties); + const dropCapDescriptor = normalizeDropCap(resolvedParagraphProperties.framePr, para, converterContext); + const normalizedListRendering = attrs.listRendering as { + markerText: string; + justification: 'left' | 'center' | 'right'; + path: number[]; + numberingType: string; + suffix: 'tab' | 'space' | 'nothing'; }; - /** - * Extracts framePr from raw OOXML elements array in paragraphProperties. - * - * This handles the case where framePr is stored as a raw OOXML element - * (from ProseMirror serialization) rather than as a decoded object. - * - * @param paragraphProperties - The paragraphProperties object that may contain elements array - * @returns The framePr attributes if found, otherwise undefined - */ - const extractFramePrFromElements = (paragraphProperties: unknown): Record | undefined => { - if (!paragraphProperties || typeof paragraphProperties !== 'object') return undefined; - const pPr = paragraphProperties as Record; - if (!Array.isArray(pPr.elements)) return undefined; - const framePrElement = pPr.elements.find((el: Record) => el.name === 'w:framePr'); - if (framePrElement?.attributes && typeof framePrElement.attributes === 'object') { - return framePrElement.attributes as Record; - } - return undefined; + const paragraphAttrs: ParagraphAttrs = { + styleId: resolvedParagraphProperties.styleId, + alignment: normalizedAlignment, + spacing: normalizedSpacing, + contextualSpacing: resolvedParagraphProperties.contextualSpacing, + indent: normalizedIndent, + dropCapDescriptor: dropCapDescriptor, + frame: normalizedFramePr, + numberingProperties: normalizedNumberingProperties, + borders: normalizedBorders, + shading: normalizedShading, + tabs: normalizedTabStops, + decimalSeparator: paragraphDecimalSeparator, + tabIntervalTwips, + keepNext: resolvedParagraphProperties.keepNext, + keepLines: resolvedParagraphProperties.keepLines, + floatAlignment: floatAlignment, + pageBreakBefore: resolvedParagraphProperties.pageBreakBefore, }; - // Extract floating alignment and positioning from framePr (OOXML w:framePr). - // Used for positioned paragraphs like right-aligned page numbers in headers/footers. - // - // Three-tier lookup strategy to handle different data sources: - // 1. attrs.framePr - Top-level framePr from the converter (most direct path) - // 2. attrs.paragraphProperties.framePr - Decoded framePr object from v3 translator - // 3. attrs.paragraphProperties.elements[name='w:framePr'] - Raw OOXML element from PM serialization - const framePr = - normalizeFramePr(attrs.framePr) ?? - normalizeFramePr((attrs.paragraphProperties as Record | undefined)?.framePr) ?? - extractFramePrFromElements(attrs.paragraphProperties); - - if (framePr) { - const rawXAlign = asString(framePr['w:xAlign'] ?? framePr.xAlign); - const xAlign = typeof rawXAlign === 'string' ? rawXAlign.toLowerCase() : undefined; - // Only set floatAlignment if xAlign is a valid value - if (xAlign === 'left' || xAlign === 'right' || xAlign === 'center') { - paragraphAttrs.floatAlignment = xAlign; - } - - const dropCap = framePr['w:dropCap'] ?? framePr.dropCap; - if ( - dropCap != null && - (typeof dropCap === 'string' || typeof dropCap === 'number' || typeof dropCap === 'boolean') - ) { - // Keep the legacy dropCap flag for backward compatibility - paragraphAttrs.dropCap = dropCap; - - // Build structured DropCapDescriptor for enhanced drop cap support - const dropCapMode = typeof dropCap === 'string' ? dropCap.toLowerCase() : 'drop'; - const linesValue = pickNumber(framePr['w:lines'] ?? framePr.lines); - const wrapValue = asString(framePr['w:wrap'] ?? framePr.wrap); - - // Extract the drop cap text and run styling from paragraph content - const dropCapRunInfo = extractDropCapRunFromParagraph(para); - - if (dropCapRunInfo) { - const descriptor: DropCapDescriptor = { - mode: dropCapMode === 'margin' ? 'margin' : 'drop', - lines: linesValue != null && linesValue > 0 ? linesValue : 3, - run: dropCapRunInfo, - }; - - // Map wrap value to the expected types - if (wrapValue) { - const normalizedWrap = wrapValue.toLowerCase(); - if ( - normalizedWrap === 'around' || - normalizedWrap === 'notbeside' || - normalizedWrap === 'none' || - normalizedWrap === 'tight' - ) { - descriptor.wrap = - normalizedWrap === 'notbeside' ? 'notBeside' : (normalizedWrap as 'around' | 'none' | 'tight'); - } - } - - paragraphAttrs.dropCapDescriptor = descriptor; - } - } - - const frame: ParagraphAttrs['frame'] = {}; - const wrap = asString(framePr['w:wrap'] ?? framePr.wrap); - if (wrap) frame.wrap = wrap; - - // Set xAlign in frame (accepts any string, validation deferred to renderer) - if (xAlign) { - frame.xAlign = xAlign as 'left' | 'right' | 'center'; - } - - // yAlign: Accept any string value, validation deferred to renderer - const rawYAlign = asString(framePr['w:yAlign'] ?? framePr.yAlign); - if (rawYAlign) { - frame.yAlign = rawYAlign as 'top' | 'center' | 'bottom'; - } - - const hAnchor = asString(framePr['w:hAnchor'] ?? framePr.hAnchor); - if (hAnchor) frame.hAnchor = hAnchor; - const vAnchor = asString(framePr['w:vAnchor'] ?? framePr.vAnchor); - if (vAnchor) frame.vAnchor = vAnchor; - - const xTwips = pickNumber(framePr['w:x'] ?? framePr.x); - if (xTwips != null) frame.x = twipsToPx(xTwips); - const yTwips = pickNumber(framePr['w:y'] ?? framePr.y); - if (yTwips != null) frame.y = twipsToPx(yTwips); - - if (Object.keys(frame).length > 0) { - paragraphAttrs.frame = frame; - } - } - - // Track B: Compute wordLayout for paragraphs with numberingProperties - const listRendering = normalizeListRenderingAttrs(attrs.listRendering); - const numberingSource = - attrs.numberingProperties ?? paragraphProps.numberingProperties ?? hydrated?.numberingProperties; - let rawNumberingProps = toAdapterNumberingProps(numberingSource); - - /** - * Fallback mechanism for table paragraphs with list rendering but no numbering properties. - * - * **Why this is needed:** - * Some document sources (particularly table cells imported from certain formats) provide - * listRendering attributes (marker text, path, styling) but lack the traditional OOXML - * numberingProperties structure (numId, ilvl). This fallback synthesizes minimal - * numbering properties from the listRendering data to ensure list markers render correctly. - * - * **When this is used:** - * - Table paragraphs that have listRendering but no numberingProperties - * - Imported documents where numbering context was lost but visual marker info was preserved - * - Fallback rendering path when traditional OOXML numbering is unavailable - * - * **Synthesis logic:** - * - `numId`: Set to -1 (sentinel value indicating synthesized/unavailable) - * - `ilvl`: Calculated from path length (path.length - 1), defaults to 0 - * - `path`: Preserved from listRendering (e.g., [1, 2, 3] for nested lists) - * - `counterValue`: Extracted from last element of path array - * - Other properties (markerText, format, justification, suffix) copied from listRendering - */ - if (!rawNumberingProps && listRendering) { - const path = listRendering.path; - const counterFromPath = path && path.length ? path[path.length - 1] : undefined; - const ilvl = path && path.length > 1 ? path.length - 1 : 0; - - rawNumberingProps = { - numId: -1, - ilvl, - path, - counterValue: Number.isFinite(counterFromPath) ? Number(counterFromPath) : undefined, - markerText: listRendering.markerText, - format: listRendering.numberingType as NumberingFormat | undefined, - lvlJc: listRendering.justification, - suffix: listRendering.suffix, - } as AdapterNumberingProps; - } - - /** - * Validates that the paragraph has valid numbering properties. - * Per OOXML spec §17.9.16, numId="0" (or '0') is a special sentinel value that disables - * numbering inherited from paragraph styles. We skip word layout processing entirely for numId=0. - */ - const hasValidNumbering = rawNumberingProps && isValidNumberingId(rawNumberingProps.numId); - if (hasValidNumbering && rawNumberingProps) { - const numberingProps = rawNumberingProps; - const numId = numberingProps.numId; - const ilvl = Number.isFinite(numberingProps.ilvl) ? Math.max(0, Math.floor(Number(numberingProps.ilvl))) : 0; - const numericNumId = typeof numId === 'number' ? numId : undefined; - - // Resolve numbering definition details (format, text, indent, marker run) from converter context - let resolvedLevel: Partial | undefined; - try { - resolvedLevel = resolveNumberingFromContext(numId, ilvl, converterContext?.numbering); - } catch (error) { - resolvedLevel = undefined; - } - - if (resolvedLevel) { - if (resolvedLevel.format && numberingProps.format == null) { - numberingProps.format = resolvedLevel.format; - } - if (resolvedLevel.lvlText && numberingProps.lvlText == null) { - numberingProps.lvlText = resolvedLevel.lvlText; - } - if (resolvedLevel.start != null && numberingProps.start == null) { - numberingProps.start = resolvedLevel.start; - } - if (resolvedLevel.suffix && numberingProps.suffix == null) { - numberingProps.suffix = resolvedLevel.suffix; - } - if (resolvedLevel.lvlJc && numberingProps.lvlJc == null) { - numberingProps.lvlJc = resolvedLevel.lvlJc; - } - if (resolvedLevel.resolvedLevelIndent && !numberingProps.resolvedLevelIndent) { - numberingProps.resolvedLevelIndent = resolvedLevel.resolvedLevelIndent; - } - if (resolvedLevel.resolvedMarkerRpr && !numberingProps.resolvedMarkerRpr) { - numberingProps.resolvedMarkerRpr = resolvedLevel.resolvedMarkerRpr; - } - } - - // Track B: Increment list counter and build path array - let counterValue = 1; - if (listCounterContext && typeof numericNumId === 'number') { - counterValue = listCounterContext.incrementListCounter(numericNumId, ilvl); - - // Reset deeper levels when returning to a shallower level - // (e.g., going from level 1 back to level 0 should reset level 1's counter) - for (let deeperLevel = ilvl + 1; deeperLevel <= 8; deeperLevel++) { - listCounterContext.resetListCounter(numericNumId, deeperLevel); - } - } - - // Build path array for multi-level numbering (e.g., "1.2.3") - const path = - (listRendering?.path && listRendering.path.length ? listRendering.path : undefined) ?? - buildNumberingPath(numericNumId, ilvl, counterValue, listCounterContext); - const resolvedCounterValue = path[path.length - 1] ?? counterValue; - - // Enrich numberingProperties with path and counter info - // Explicitly include numId and ilvl to satisfy TypeScript since they are required - const enrichedNumberingProps: AdapterNumberingProps = { - ...numberingProps, - numId: numberingProps.numId, - ilvl: numberingProps.ilvl, - path, - counterValue: resolvedCounterValue, - }; - - if (listRendering?.numberingType && enrichedNumberingProps.format == null) { - enrichedNumberingProps.format = listRendering.numberingType as NumberingFormat; - } - if (listRendering?.markerText && enrichedNumberingProps.markerText == null) { - enrichedNumberingProps.markerText = listRendering.markerText; - } - if (listRendering?.justification && enrichedNumberingProps.lvlJc == null) { - enrichedNumberingProps.lvlJc = listRendering.justification; - } - if (listRendering?.suffix && enrichedNumberingProps.suffix == null) { - enrichedNumberingProps.suffix = listRendering.suffix; - } - - // Try to get marker run properties from numbering definition if not pre-resolved - // Do NOT set hardcoded defaults here - let computeWordLayoutForParagraph use - // style-engine fallback to resolve from paragraph style (matching MS Word behavior) - if (!enrichedNumberingProps.resolvedMarkerRpr) { - const numbering = computed.numbering as unknown as Record | undefined; - if (numbering && typeof numbering.marker === 'object' && numbering.marker !== null) { - const marker = numbering.marker as Record; - if (typeof marker.run === 'object' && marker.run !== null) { - enrichedNumberingProps.resolvedMarkerRpr = marker.run as ResolvedRunProperties; - } - } - // NOTE: If still not resolved, computeWordLayoutForParagraph will use - // style-engine to resolve from paragraph style, which is the correct MS Word behavior - } - - let wordLayout: WordParagraphLayoutOutput | null = null; - try { - wordLayout = computeWordLayoutForParagraph( - paragraphAttrs, - enrichedNumberingProps, - styleContext, - para, - converterContext, - hydrated?.resolved as Record | undefined, - ); - } catch (error) { - wordLayout = null; - } - - // Fallback: some numbering levels only specify a firstLine indent (no left/hanging). - // When wordLayout computation returns null, ensure we still provide a textStartPx - // so first-line wrapping in columns has the correct width. - if (!wordLayout && enrichedNumberingProps.resolvedLevelIndent) { - const resolvedIndentPx = convertIndentTwipsToPx(enrichedNumberingProps.resolvedLevelIndent); - const baseIndent = resolvedIndentPx ?? enrichedNumberingProps.resolvedLevelIndent; - const mergedIndent = { ...baseIndent, ...(paragraphAttrs.indent ?? {}) }; - const firstLinePx = isFiniteNumber(mergedIndent.firstLine) ? mergedIndent.firstLine : 0; - const hangingPx = isFiniteNumber(mergedIndent.hanging) ? mergedIndent.hanging : 0; - if (firstLinePx > 0 && !hangingPx) { - wordLayout = { - // Treat as first-line-indent mode: text starts after the marker+firstLine offset. - firstLineIndentMode: true, - textStartPx: firstLinePx, - } as WordParagraphLayoutOutput; - } - } - - // If computeWordLayout returned an object but did not provide textStartPx and - // the numbering indent has a firstLine value, set a minimal textStartPx to - // match the resolved first-line indent. This guards against cases where - // word-layout computation omits textStart for levels without left/hanging. - if (wordLayout && !Number.isFinite(wordLayout.textStartPx) && enrichedNumberingProps.resolvedLevelIndent) { - const resolvedIndentPx = convertIndentTwipsToPx(enrichedNumberingProps.resolvedLevelIndent); - const baseIndent = resolvedIndentPx ?? enrichedNumberingProps.resolvedLevelIndent; - const mergedIndent = { ...baseIndent, ...(paragraphAttrs.indent ?? {}) }; - const firstLinePx = isFiniteNumber(mergedIndent.firstLine) ? mergedIndent.firstLine : 0; - const hangingPx = isFiniteNumber(mergedIndent.hanging) ? mergedIndent.hanging : 0; - if (firstLinePx > 0 && !hangingPx) { - wordLayout = { - ...wordLayout, - firstLineIndentMode: wordLayout.firstLineIndentMode ?? true, - textStartPx: firstLinePx, - }; - } - } - - if (wordLayout) { - if (wordLayout.marker) { - if (listRendering?.markerText) { - wordLayout.marker.markerText = listRendering.markerText; - } - if (listRendering?.justification) { - wordLayout.marker.justification = listRendering.justification; - } - if (listRendering?.suffix) { - wordLayout.marker.suffix = listRendering.suffix; - } - } - wordLayout = normalizeWordLayoutForIndent(wordLayout, paragraphAttrs.indent); - paragraphAttrs.wordLayout = wordLayout; - } - - // Always merge resolvedLevelIndent into paragraphAttrs.indent, regardless of wordLayout success. - // This ensures sublists get correct indentation even if wordLayout computation fails. - // Per OOXML spec, paragraph indent MERGES with numbering definition: - // - Numbering definition provides base values (left, hanging from level) - // - Paragraph's explicit indent properties override specific values - // - Missing paragraph indent properties inherit from numbering definition - // This fixes cases where a paragraph only specifies w:hanging but should - // inherit w:left from the numbering level definition. - if (enrichedNumberingProps.resolvedLevelIndent) { - const resolvedIndentPx = convertIndentTwipsToPx(enrichedNumberingProps.resolvedLevelIndent); - const baseIndent = resolvedIndentPx ?? enrichedNumberingProps.resolvedLevelIndent; - - // Merge: numbering definition as base, paragraph explicit values override - paragraphAttrs.indent = { - ...baseIndent, - ...(normalizedIndent ?? {}), - }; - - // In OOXML, hanging and firstLine are mutually exclusive. - // If the paragraph explicitly specifies one, the other should be cleared. - // This ensures proper marker positioning when paragraph overrides numbering indent. - if (normalizedIndent?.firstLine !== undefined) { - delete paragraphAttrs.indent.hanging; - } else if (normalizedIndent?.hanging !== undefined) { - delete paragraphAttrs.indent.firstLine; - } - } - - // Preserve numberingProperties for downstream consumers (e.g., measurement stage) - paragraphAttrs.numberingProperties = enrichedNumberingProps as Record; + if (normalizedNumberingProperties && normalizedListRendering) { + const markerRunProperties = resolveRunProperties( + converterContext!, + resolvedParagraphProperties.runProperties, + resolvedParagraphProperties, + converterContext!.tableInfo, + true, + Boolean(paragraphProperties.numberingProperties), + ); + paragraphAttrs.wordLayout = computeWordParagraphLayout({ + paragraph: paragraphAttrs, + listRenderingAttrs: normalizedListRendering, + markerRun: computeRunAttrs(markerRunProperties, converterContext), + }); } - return Object.keys(paragraphAttrs).length > 0 ? paragraphAttrs : undefined; + return { paragraphAttrs, resolvedParagraphProperties }; }; -/** - * Merge two paragraph attributes, with override taking precedence. - */ -export const mergeParagraphAttrs = (base?: ParagraphAttrs, override?: ParagraphAttrs): ParagraphAttrs | undefined => { - if (!base && !override) return undefined; - if (!base) return override; - if (!override) return base; - - const merged: ParagraphAttrs = { ...base }; - if (override.alignment) { - merged.alignment = override.alignment; - } - if (override.spacing) { - merged.spacing = { ...(base.spacing ?? {}), ...override.spacing }; - } - if (override.indent) { - merged.indent = { ...(base.indent ?? {}), ...override.indent }; - // In OOXML, hanging and firstLine are mutually exclusive. - // If override specifies one, clear the other from the merged result. - if (override.indent.firstLine !== undefined) { - delete merged.indent.hanging; - } else if (override.indent.hanging !== undefined) { - delete merged.indent.firstLine; - } - } - if (override.borders) { - merged.borders = { ...(base.borders ?? {}), ...override.borders }; - } - if (override.shading) { - merged.shading = { ...(base.shading ?? {}), ...override.shading }; +export const computeRunAttrs = ( + runProps: RunProperties, + converterContext?: ConverterContext, + defaultFontSizePx = 12, + defaultFontFamily = 'Times New Roman', +): ResolvedRunProperties => { + let fontFamily; + if (converterContext) { + fontFamily = + resolveDocxFontFamily(runProps.fontFamily as Record, converterContext.docx) || defaultFontFamily; + } else { + fontFamily = + runProps.fontFamily?.ascii || runProps.fontFamily?.hAnsi || runProps.fontFamily?.eastAsia || defaultFontFamily; } - return merged; -}; - -/** - * Convert list paragraph attributes to paragraph attrs format. - */ -export const convertListParagraphAttrs = (attrs?: Record): ParagraphAttrs | undefined => { - if (!attrs) return undefined; - const paragraphAttrs: ParagraphAttrs = {}; - - const alignment = normalizeAlignment(attrs.alignment ?? attrs.lvlJc); - if (alignment) paragraphAttrs.alignment = alignment; - - const spacing = normalizeParagraphSpacing(attrs.spacing); - if (spacing) paragraphAttrs.spacing = spacing; - - const shading = normalizeParagraphShading(attrs.shading); - if (shading) paragraphAttrs.shading = shading; - - return Object.keys(paragraphAttrs).length > 0 ? paragraphAttrs : undefined; + return { + fontFamily: toCssFontFamily(fontFamily)!, + fontSize: runProps.fontSize ? ptToPx(runProps.fontSize / 2)! : defaultFontSizePx, + bold: runProps.bold, + italic: runProps.italic, + underline: + runProps.underline && runProps.underline!['w:val'] && runProps.underline!['w:val'] !== 'none' + ? { + style: (runProps.underline!['w:val'] as 'single' | 'double' | 'dotted' | 'dashed' | 'wavy') || 'single', + color: runProps.underline!['w:color'] || undefined, + } + : null, + strike: runProps.strike, + color: normalizeColor(runProps.color?.val), + highlight: runProps.highlight?.['w:val'] || undefined, + smallCaps: runProps.smallCaps, + allCaps: runProps?.textTransform === 'uppercase', + letterSpacing: runProps.letterSpacing ? twipsToPx(runProps.letterSpacing) : undefined, + lang: runProps.lang?.val || undefined, + }; }; diff --git a/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.test.ts b/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.test.ts index 2ad7f9ff08..2a2a60991a 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.test.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.test.ts @@ -1,846 +1,149 @@ /** * Tests for Spacing & Indent Normalization Module * - * Covers 9 functions for converting spacing/indent between units and normalizing values: - * - spacingPxToPt: Convert spacing from pixels to points - * - indentPxToPt: Convert indent from pixels to points - * - spacingPtToPx: Convert spacing from points to pixels (with rawSpacing filter) - * - indentPtToPx: Convert indent from points to pixels - * - normalizeAlignment: Normalize alignment values - * - normalizeParagraphSpacing: Normalize spacing from raw attributes - * - normalizeLineRule: Normalize lineRule values - * - normalizePxIndent: Normalize indent already in pixels (with twips detection) - * - normalizeParagraphIndent: Normalize indent with twips→px conversion - * - * Critical: Tests include the twips detection heuristic (threshold = 50, divisor = 15) + * Covers: + * - normalizeAlignment + * - normalizeParagraphSpacing + * - normalizeLineRule + * - indent conversion via computeParagraphAttrs (twips -> px) */ import { describe, it, expect } from 'vitest'; import type { ParagraphIndent, ParagraphSpacing } from '@superdoc/contracts'; -import { - spacingPxToPt, - indentPxToPt, - spacingPtToPx, - indentPtToPx, - normalizeAlignment, - normalizeParagraphSpacing, - normalizeLineRule, - normalizePxIndent, - normalizeParagraphIndent, -} from './spacing-indent.js'; +import { normalizeAlignment, normalizeParagraphSpacing, normalizeLineRule } from './spacing-indent.js'; +import { computeParagraphAttrs } from './paragraph.js'; import { twipsToPx } from '../utilities.js'; -describe('spacingPxToPt', () => { - it('should convert before spacing from px to pt', () => { - const spacing: ParagraphSpacing = { before: 12 }; - const result = spacingPxToPt(spacing); - // 12px = 9pt (at 96 DPI: pt = px * 0.75) - expect(result.before).toBe(9); - }); - - it('should convert after spacing from px to pt', () => { - const spacing: ParagraphSpacing = { after: 16 }; - const result = spacingPxToPt(spacing); - expect(result.after).toBe(12); - }); - - it('should convert line spacing from px to pt', () => { - const spacing: ParagraphSpacing = { line: 20 }; - const result = spacingPxToPt(spacing); - expect(result.line).toBe(15); - }); - - it('should preserve auto line spacing multipliers', () => { - const spacing: ParagraphSpacing = { line: 1.15, lineRule: 'auto' }; - const result = spacingPxToPt(spacing); - expect(result.line).toBe(1.15); - expect(result.lineRule).toBe('auto'); - }); - - it('should preserve lineRule', () => { - const spacing: ParagraphSpacing = { line: 20, lineRule: 'exact' }; - const result = spacingPxToPt(spacing); - expect(result.lineRule).toBe('exact'); - }); - - it('should convert all spacing properties', () => { - const spacing: ParagraphSpacing = { - before: 8, - after: 12, - line: 16, - lineRule: 'auto', - }; - const result = spacingPxToPt(spacing); - expect(result.before).toBe(6); - expect(result.after).toBe(9); - expect(result.line).toBe(12); - expect(result.lineRule).toBe('auto'); - }); - - it('should handle zero values', () => { - const spacing: ParagraphSpacing = { before: 0, after: 0, line: 0 }; - const result = spacingPxToPt(spacing); - expect(result.before).toBe(0); - expect(result.after).toBe(0); - expect(result.line).toBe(0); - }); - - it('should handle negative values', () => { - const spacing: ParagraphSpacing = { before: -12 }; - const result = spacingPxToPt(spacing); - expect(result.before).toBe(-9); - }); - - it('should handle fractional values', () => { - const spacing: ParagraphSpacing = { before: 10.5 }; - const result = spacingPxToPt(spacing); - expect(result.before).toBe(7.875); - }); - - it('should skip undefined properties', () => { - const spacing: ParagraphSpacing = { before: 12 }; - const result = spacingPxToPt(spacing); - expect(result.after).toBeUndefined(); - expect(result.line).toBeUndefined(); - }); - - it('should return empty object for empty spacing', () => { - const spacing: ParagraphSpacing = {}; - const result = spacingPxToPt(spacing); - expect(result).toEqual({}); - }); -}); - -describe('indentPxToPt', () => { - it('should convert left indent from px to pt', () => { - const indent: ParagraphIndent = { left: 24 }; - const result = indentPxToPt(indent); - expect(result.left).toBe(18); - }); - - it('should convert right indent from px to pt', () => { - const indent: ParagraphIndent = { right: 32 }; - const result = indentPxToPt(indent); - expect(result.right).toBe(24); - }); - - it('should convert firstLine indent from px to pt', () => { - const indent: ParagraphIndent = { firstLine: 16 }; - const result = indentPxToPt(indent); - expect(result.firstLine).toBe(12); - }); - - it('should convert hanging indent from px to pt', () => { - const indent: ParagraphIndent = { hanging: 20 }; - const result = indentPxToPt(indent); - expect(result.hanging).toBe(15); - }); - - it('should convert all indent properties', () => { - const indent: ParagraphIndent = { - left: 24, - right: 32, - firstLine: 16, - hanging: 20, - }; - const result = indentPxToPt(indent); - expect(result.left).toBe(18); - expect(result.right).toBe(24); - expect(result.firstLine).toBe(12); - expect(result.hanging).toBe(15); - }); - - it('should handle zero values', () => { - const indent: ParagraphIndent = { left: 0, right: 0 }; - const result = indentPxToPt(indent); - expect(result.left).toBe(0); - expect(result.right).toBe(0); - }); - - it('should handle negative values', () => { - const indent: ParagraphIndent = { firstLine: -12 }; - const result = indentPxToPt(indent); - expect(result.firstLine).toBe(-9); - }); -}); - -describe('spacingPtToPx', () => { - describe('with rawSpacing filter', () => { - it('should convert before when present in rawSpacing', () => { - const spacing = { before: 9, after: 12 }; - const rawSpacing: ParagraphSpacing = { before: 10 }; - const result = spacingPtToPx(spacing, rawSpacing); - expect(result?.before).toBe(12); // 9pt = 12px - expect(result?.after).toBeUndefined(); - }); - - it('should convert after when present in rawSpacing', () => { - const spacing = { before: 9, after: 12 }; - const rawSpacing: ParagraphSpacing = { after: 15 }; - const result = spacingPtToPx(spacing, rawSpacing); - expect(result?.before).toBeUndefined(); - expect(result?.after).toBe(16); // 12pt = 16px - }); - - it('should convert line when present in rawSpacing', () => { - const spacing = { line: 15 }; - const rawSpacing: ParagraphSpacing = { line: 20 }; - const result = spacingPtToPx(spacing, rawSpacing); - expect(result?.line).toBe(20); // 15pt = 20px - }); - - it('should preserve auto line multipliers when present in rawSpacing', () => { - const spacing = { line: 1.15, lineRule: 'auto' as const }; - const rawSpacing: ParagraphSpacing = { line: 20 }; - const result = spacingPtToPx(spacing, rawSpacing); - expect(result?.line).toBe(1.15); - expect(result?.lineRule).toBe('auto'); - }); +const getIndent = (indent: ParagraphIndent | null | undefined) => { + const para = { + type: 'paragraph', + attrs: { + paragraphProperties: { + indent, + }, + }, + } as never; + const { paragraphAttrs } = computeParagraphAttrs(para); + return paragraphAttrs.indent; +}; - it('should preserve lineRule when converting line', () => { - const spacing = { line: 15, lineRule: 'exact' as const }; - const rawSpacing: ParagraphSpacing = { line: 20 }; - const result = spacingPtToPx(spacing, rawSpacing); - expect(result?.lineRule).toBe('exact'); - }); - - it('should convert all properties present in rawSpacing', () => { - const spacing = { before: 9, after: 12, line: 15, lineRule: 'auto' as const }; - const rawSpacing: ParagraphSpacing = { before: 10, after: 15, line: 20 }; - const result = spacingPtToPx(spacing, rawSpacing); - expect(result?.before).toBe(12); - expect(result?.after).toBe(16); - expect(result?.line).toBe(20); - expect(result?.lineRule).toBe('auto'); - }); - - it('should return undefined when rawSpacing is empty', () => { - const spacing = { before: 9, after: 12 }; - const rawSpacing: ParagraphSpacing = {}; - const result = spacingPtToPx(spacing, rawSpacing); - expect(result).toBeUndefined(); - }); - }); - - describe('without rawSpacing', () => { - it('should return undefined when rawSpacing is not provided', () => { - const spacing = { before: 9, after: 12 }; - const result = spacingPtToPx(spacing); - expect(result).toBeUndefined(); - }); - - it('should return undefined when rawSpacing is undefined', () => { - const spacing = { before: 9, after: 12 }; - const result = spacingPtToPx(spacing, undefined); - expect(result).toBeUndefined(); - }); - }); -}); - -describe('indentPtToPx', () => { - it('should convert left indent from pt to px', () => { - const indent = { left: 18 }; - const result = indentPtToPx(indent); - expect(result?.left).toBe(24); // 18pt = 24px - }); - - it('should convert right indent from pt to px', () => { - const indent = { right: 24 }; - const result = indentPtToPx(indent); - expect(result?.right).toBe(32); // 24pt = 32px - }); - - it('should convert firstLine indent from pt to px', () => { - const indent = { firstLine: 12 }; - const result = indentPtToPx(indent); - expect(result?.firstLine).toBe(16); // 12pt = 16px - }); - - it('should convert hanging indent from pt to px', () => { - const indent = { hanging: 15 }; - const result = indentPtToPx(indent); - expect(result?.hanging).toBe(20); // 15pt = 20px +describe('normalizeParagraphSpacing', () => { + it('converts before/after from twips to px', () => { + const spacing = { before: 240, after: 360 } as ParagraphSpacing; // 16px, 24px + const result = normalizeParagraphSpacing(spacing); + expect(result?.before).toBe(twipsToPx(240)); + expect(result?.after).toBe(twipsToPx(360)); }); - it('should filter out zero values', () => { - const indent = { left: 0, right: 0 }; - const result = indentPtToPx(indent); - expect(result).toBeUndefined(); + it('converts line from twips to px when lineRule is exact', () => { + const spacing = { line: 360, lineRule: 'exact' as const } as ParagraphSpacing; // 24px + const result = normalizeParagraphSpacing(spacing); + expect(result?.line).toBe(twipsToPx(360)); + expect(result?.lineRule).toBe('exact'); }); - it('should filter out individual zero values', () => { - const indent = { left: 18, right: 0, firstLine: 12 }; - const result = indentPtToPx(indent); - expect(result?.left).toBe(24); - expect(result?.right).toBeUndefined(); - expect(result?.firstLine).toBe(16); + it('treats auto line values <= 10 as multipliers', () => { + const spacing = { line: 1.15, lineRule: 'auto' as const } as ParagraphSpacing; + const result = normalizeParagraphSpacing(spacing); + expect(result?.line).toBe(1.15); + expect(result?.lineRule).toBe('auto'); }); - it('should handle negative values', () => { - const indent = { firstLine: -12 }; - const result = indentPtToPx(indent); - expect(result?.firstLine).toBe(-16); + it('converts auto line values > 10 from 240ths of a line', () => { + const spacing = { line: 360, lineRule: 'auto' as const } as ParagraphSpacing; // 1.5x + const result = normalizeParagraphSpacing(spacing); + expect(result?.line).toBe(1.5); + expect(result?.lineRule).toBe('auto'); }); - it('should convert all non-zero properties and preserve zero firstLine/hanging', () => { - const indent = { left: 18, right: 24, firstLine: 0, hanging: 15 }; - const result = indentPtToPx(indent); - expect(result?.left).toBe(24); - expect(result?.right).toBe(32); - // firstLine: 0 is preserved (explicit override for numbering) - expect(result?.firstLine).toBe(0); - expect(result?.hanging).toBe(20); + it('preserves contextual spacing flags', () => { + const spacing = { before: 240, beforeAutospacing: true, afterAutospacing: false } as ParagraphSpacing; + const result = normalizeParagraphSpacing(spacing); + expect(result?.before).toBe(twipsToPx(240)); + expect(result?.beforeAutospacing).toBe(true); + expect(result?.afterAutospacing).toBe(false); }); - it('should preserve zero values for firstLine and hanging (meaningful overrides)', () => { - // Zero values for firstLine/hanging are meaningful - they override numbering level indents - const indent = { left: 0, right: 0, firstLine: 0, hanging: 0 }; - const result = indentPtToPx(indent); - // firstLine: 0 and hanging: 0 are preserved as explicit overrides - expect(result).toEqual({ firstLine: 0, hanging: 0 }); + it('returns undefined for empty or invalid inputs', () => { + expect(normalizeParagraphSpacing(undefined)).toBeUndefined(); + expect(normalizeParagraphSpacing(null as never)).toBeUndefined(); + expect(normalizeParagraphSpacing({} as ParagraphSpacing)).toBeUndefined(); }); - it('should handle undefined properties', () => { - const indent = { left: 18 }; - const result = indentPtToPx(indent); - expect(result?.left).toBe(24); - expect(result?.right).toBeUndefined(); + it('skips non-numeric values but preserves valid ones', () => { + const spacing = { before: 'not-a-number', after: 300 } as unknown as ParagraphSpacing; + const result = normalizeParagraphSpacing(spacing); + expect(result?.before).toBeUndefined(); + expect(result?.after).toBe(twipsToPx(300)); }); }); describe('normalizeAlignment', () => { - it('should return "center" for center', () => { - expect(normalizeAlignment('center')).toBe('center'); - }); - - it('should return "right" for right', () => { + it('normalizes alignment values', () => { + expect(normalizeAlignment('left')).toBe('left'); expect(normalizeAlignment('right')).toBe('right'); - }); - - it('should return "justify" for justify', () => { + expect(normalizeAlignment('center')).toBe('center'); expect(normalizeAlignment('justify')).toBe('justify'); }); - it('should convert "end" to "right"', () => { - expect(normalizeAlignment('end')).toBe('right'); - }); - - it('should convert "start" to "left"', () => { + it('maps start/end to left/right', () => { expect(normalizeAlignment('start')).toBe('left'); + expect(normalizeAlignment('end')).toBe('right'); }); - it('should return "left" for "left"', () => { - // Explicit left alignment must be returned so it can override style-based center/right - expect(normalizeAlignment('left')).toBe('left'); - }); - - it('should return undefined for unknown values', () => { + it('returns undefined for invalid values', () => { expect(normalizeAlignment('unknown')).toBeUndefined(); - expect(normalizeAlignment('top')).toBeUndefined(); - expect(normalizeAlignment('bottom')).toBeUndefined(); - }); - - it('should return undefined for non-string values', () => { - expect(normalizeAlignment(null)).toBeUndefined(); - expect(normalizeAlignment(undefined)).toBeUndefined(); expect(normalizeAlignment(123)).toBeUndefined(); - expect(normalizeAlignment({})).toBeUndefined(); - }); - - it('should be case-sensitive', () => { - // The function uses strict equality, so it's case-sensitive - expect(normalizeAlignment('CENTER')).toBeUndefined(); - expect(normalizeAlignment('Right')).toBeUndefined(); - expect(normalizeAlignment('JUSTIFY')).toBeUndefined(); - }); - - it('should convert "both" to "justify"', () => { - expect(normalizeAlignment('both')).toBe('justify'); - }); - - it('should convert "distribute" to "justify"', () => { - expect(normalizeAlignment('distribute')).toBe('justify'); - }); - - it('should convert "numTab" to "justify"', () => { - expect(normalizeAlignment('numTab')).toBe('justify'); - }); - - it('should convert "thaiDistribute" to "justify"', () => { - expect(normalizeAlignment('thaiDistribute')).toBe('justify'); - }); -}); - -describe('normalizeParagraphSpacing', () => { - describe('valid spacing', () => { - it('should normalize before spacing', () => { - const input = { before: 150 }; // 10px in twips (10 * 15) - const result = normalizeParagraphSpacing(input); - expect(result?.before).toBe(10); - }); - - it('should normalize after spacing', () => { - const input = { after: 300 }; // 20px in twips (20 * 15) - const result = normalizeParagraphSpacing(input); - expect(result?.after).toBe(20); - }); - - it('should normalize line spacing with default auto lineRule', () => { - const input = { line: 225 }; // 225/240 = 0.9375 lines - const result = normalizeParagraphSpacing(input); - expect(result?.line).toBeCloseTo(0.9375); - expect(result?.lineRule).toBe('auto'); - }); - - it('should normalize lineRule', () => { - const input = { lineRule: 'exact' }; - const result = normalizeParagraphSpacing(input); - expect(result?.lineRule).toBe('exact'); - }); - - it('should normalize complete spacing', () => { - const input = { before: 1.5, after: 2, line: 1.2, lineRule: 'auto' }; // Auto line uses multipliers - const result = normalizeParagraphSpacing(input); - expect(result).toEqual({ - before: 0.1, // 1.5 twips in px - after: 0.13333333333333333, // 2 twips in px - line: 1.2, // Multiplier preserved (<= 10) - lineRule: 'auto', - }); - }); - - it('should convert before/after values from twips to pixels', () => { - const input = { before: 720, after: 360 }; - const result = normalizeParagraphSpacing(input); - expect(result?.before).toBe(48); - expect(result?.after).toBe(24); - }); - - it('should prefer OOXML spacing over pixel fallbacks', () => { - const input = { before: 720, lineSpaceBefore: 12 }; - const result = normalizeParagraphSpacing(input); - expect(result?.before).toBe(48); - }); - - it('should convert line spacing from twips when lineRule is exact', () => { - const input = { line: 276, lineRule: 'exact' }; - const result = normalizeParagraphSpacing(input); - expect(result?.line).toBeCloseTo(twipsToPx(276)); - }); - - it('should convert line spacing from twips when lineRule is atLeast', () => { - const input = { line: 360, lineRule: 'atLeast' }; - const result = normalizeParagraphSpacing(input); - expect(result?.line).toBeCloseTo(twipsToPx(360)); - }); - - it('should preserve multiplier line spacing when value <= 10', () => { - const input = { line: 1.5, lineRule: 'auto' }; - const result = normalizeParagraphSpacing(input); - expect(result?.line).toBe(1.5); - }); - - it('should convert auto line spacing values from 240ths of a line', () => { - const input = { line: 480, lineRule: 'auto' }; - const result = normalizeParagraphSpacing(input); - expect(result?.line).toBeCloseTo(2); - }); - - it('should handle zero values', () => { - const input = { before: 0, after: 0, line: 0 }; - const result = normalizeParagraphSpacing(input); - expect(result?.before).toBe(0); - expect(result?.after).toBe(0); - expect(result?.line).toBe(0); - }); - - it('should handle negative values', () => { - const input = { before: -150 }; // -10px in twips - const result = normalizeParagraphSpacing(input); - expect(result?.before).toBe(-10); - }); - - it('should handle fractional values', () => { - const input = { before: 157.5, after: 311.25 }; // 10.5px and 20.75px in twips - const result = normalizeParagraphSpacing(input); - expect(result?.before).toBe(10.5); - expect(result?.after).toBeCloseTo(20.75, 1); - }); - - it('should not set lineRule when line is absent', () => { - const input = { before: 240, after: 240 }; - const result = normalizeParagraphSpacing(input); - expect(result?.lineRule).toBeUndefined(); - }); - }); - - describe('property fallbacks', () => { - it('should use lineSpaceBefore as fallback for before', () => { - const input = { lineSpaceBefore: 15 }; - const result = normalizeParagraphSpacing(input); - expect(result?.before).toBe(15); - }); - - it('should use lineSpaceAfter as fallback for after', () => { - const input = { lineSpaceAfter: 25 }; - const result = normalizeParagraphSpacing(input); - expect(result?.after).toBe(25); - }); - - it('should prioritize before over lineSpaceBefore', () => { - const input = { before: 150, lineSpaceBefore: 15 }; // 10px in twips, lineSpaceBefore is already px - const result = normalizeParagraphSpacing(input); - expect(result?.before).toBe(10); - }); - - it('should prioritize after over lineSpaceAfter', () => { - const input = { after: 300, lineSpaceAfter: 25 }; // 20px in twips, lineSpaceAfter is already px - const result = normalizeParagraphSpacing(input); - expect(result?.after).toBe(20); - }); - }); - - describe('invalid inputs', () => { - it('should return undefined for null', () => { - expect(normalizeParagraphSpacing(null)).toBeUndefined(); - }); - - it('should return undefined for undefined', () => { - expect(normalizeParagraphSpacing(undefined)).toBeUndefined(); - }); - - it('should return undefined for non-object', () => { - expect(normalizeParagraphSpacing('string')).toBeUndefined(); - expect(normalizeParagraphSpacing(123)).toBeUndefined(); - }); - - it('should return undefined for empty object', () => { - expect(normalizeParagraphSpacing({})).toBeUndefined(); - }); - - it('should filter out non-numeric string values (was NaN, now fixed)', () => { - // Fixed: pickNumber now filters out NaN from parseFloat - const input = { before: 'not a number', after: 300 }; // 20px in twips - const result = normalizeParagraphSpacing(input); - expect(result?.before).toBeUndefined(); - expect(result?.after).toBe(20); - }); - - it('should filter out NaN values', () => { - const input = { before: NaN, after: 300 }; // 20px in twips - const result = normalizeParagraphSpacing(input); - expect(result?.before).toBeUndefined(); - expect(result?.after).toBe(20); - }); - - it('should filter out Infinity values', () => { - const input = { before: Infinity, after: 300 }; // 20px in twips - const result = normalizeParagraphSpacing(input); - expect(result?.before).toBeUndefined(); - expect(result?.after).toBe(20); - }); - - it('should filter out -Infinity values', () => { - const input = { before: -Infinity, line: 10 }; - const result = normalizeParagraphSpacing(input); - expect(result?.before).toBeUndefined(); - expect(result?.line).toBeDefined(); - }); }); }); describe('normalizeLineRule', () => { - it('should return "auto" for auto', () => { + it('returns valid line rules', () => { expect(normalizeLineRule('auto')).toBe('auto'); - }); - - it('should return "exact" for exact', () => { expect(normalizeLineRule('exact')).toBe('exact'); - }); - - it('should return "atLeast" for atLeast', () => { expect(normalizeLineRule('atLeast')).toBe('atLeast'); }); - it('should return undefined for invalid values', () => { + it('returns undefined for invalid values', () => { expect(normalizeLineRule('unknown')).toBeUndefined(); - expect(normalizeLineRule('multiple')).toBeUndefined(); - }); - - it('should return undefined for non-string values', () => { expect(normalizeLineRule(null)).toBeUndefined(); - expect(normalizeLineRule(123)).toBeUndefined(); - }); - - it('should be case-sensitive', () => { - expect(normalizeLineRule('AUTO')).toBeUndefined(); - expect(normalizeLineRule('Exact')).toBeUndefined(); - expect(normalizeLineRule('ATLEAST')).toBeUndefined(); }); }); -describe('normalizePxIndent', () => { - describe('pixel values (not twips)', () => { - it('should normalize small pixel values (< 50)', () => { - const input = { left: 10, right: 20 }; - const result = normalizePxIndent(input); - expect(result).toEqual({ left: 10, right: 20 }); - }); - - it('should normalize values at threshold (50)', () => { - const input = { left: 49 }; - const result = normalizePxIndent(input); - // 49 < 50, so treated as pixels - expect(result).toEqual({ left: 49 }); - }); - - it('should handle negative small values', () => { - const input = { firstLine: -10 }; - const result = normalizePxIndent(input); - expect(result).toEqual({ firstLine: -10 }); - }); - - it('should preserve zero values (explicit indent reset)', () => { - // Zero is now explicitly excluded from the divisibility check to - // support intentional zero overrides (e.g., style's firstLine=0 to - // cancel numbering level's firstLine indent) - const input = { left: 0, right: 0 }; - const result = normalizePxIndent(input); - expect(result).toEqual({ left: 0, right: 0 }); - }); - - it('should handle all four indent properties', () => { - const input = { left: 10, right: 20, firstLine: 5, hanging: 8 }; - const result = normalizePxIndent(input); - expect(result).toEqual({ left: 10, right: 20, firstLine: 5, hanging: 8 }); - }); +describe('indent conversion via computeParagraphAttrs', () => { + it('converts left/right indents from twips to px', () => { + const result = getIndent({ left: 720, right: 1440 }); + expect(result?.left).toBe(twipsToPx(720)); + expect(result?.right).toBe(twipsToPx(1440)); }); - describe('twips detection', () => { - it('should return undefined for values >= 50', () => { - const input = { left: 50 }; - const result = normalizePxIndent(input); - expect(result).toBeUndefined(); - }); - - it('should return undefined for large values (likely twips)', () => { - const input = { left: 1440 }; // 1 inch in twips - const result = normalizePxIndent(input); - expect(result).toBeUndefined(); - }); - - it('should return undefined for values divisible by 15', () => { - const input = { left: 30 }; // 30 % 15 = 0 - const result = normalizePxIndent(input); - expect(result).toBeUndefined(); - }); - - it('should return undefined for values divisible by 15 (45)', () => { - const input = { left: 45 }; // 45 % 15 = 0 - const result = normalizePxIndent(input); - expect(result).toBeUndefined(); - }); - - it('should return undefined when any value looks like twips', () => { - const input = { left: 10, right: 720 }; // right is twips - const result = normalizePxIndent(input); - expect(result).toBeUndefined(); - }); - - it('should accept values not divisible by 15', () => { - const input = { left: 16, right: 17 }; // Neither divisible by 15 - const result = normalizePxIndent(input); - expect(result).toEqual({ left: 16, right: 17 }); - }); - - it('should handle negative values with twips detection', () => { - const input = { left: -60 }; // |−60| >= 50 - const result = normalizePxIndent(input); - expect(result).toBeUndefined(); - }); + it('converts firstLine and hanging indents from twips to px', () => { + const result = getIndent({ firstLine: 360, hanging: 180 }); + expect(result?.firstLine).toBe(twipsToPx(360)); + expect(result?.hanging).toBe(twipsToPx(180)); }); - describe('invalid inputs', () => { - it('should return undefined for null', () => { - expect(normalizePxIndent(null)).toBeUndefined(); - }); - - it('should return undefined for undefined', () => { - expect(normalizePxIndent(undefined)).toBeUndefined(); - }); - - it('should return undefined for non-object', () => { - expect(normalizePxIndent('string')).toBeUndefined(); - expect(normalizePxIndent(123)).toBeUndefined(); - }); - - it('should return undefined for empty object', () => { - expect(normalizePxIndent({})).toBeUndefined(); - }); - - it('should skip non-numeric values', () => { - const input = { left: 10, right: 'not a number' }; - const result = normalizePxIndent(input); - expect(result).toEqual({ left: 10 }); - }); - - it('should skip NaN values', () => { - const input = { left: NaN, right: 10 }; - const result = normalizePxIndent(input); - expect(result).toEqual({ right: 10 }); - }); - - it('should skip Infinity values', () => { - const input = { left: Infinity, right: 10 }; - const result = normalizePxIndent(input); - expect(result).toEqual({ right: 10 }); - }); + it('preserves zero and negative values via conversion', () => { + const result = getIndent({ left: 0, firstLine: -720 }); + expect(result?.left).toBe(0); + expect(result?.firstLine).toBe(twipsToPx(-720)); }); -}); - -describe('normalizeParagraphIndent', () => { - describe('pixel values (≤ 50)', () => { - it('should preserve small values as pixels', () => { - const input = { left: 10, right: 20 }; - const result = normalizeParagraphIndent(input); - expect(result).toEqual({ left: 10, right: 20 }); - }); - - it('should preserve values at threshold (50)', () => { - const input = { left: 50 }; - const result = normalizeParagraphIndent(input); - expect(result).toEqual({ left: 50 }); - }); - - it('should preserve negative small values', () => { - const input = { firstLine: -20 }; - const result = normalizeParagraphIndent(input); - expect(result).toEqual({ firstLine: -20 }); - }); - it('should preserve zero values', () => { - const input = { left: 0, right: 0 }; - const result = normalizeParagraphIndent(input); - expect(result).toEqual({ left: 0, right: 0 }); - }); - - it('should preserve all four indent properties when small', () => { - const input = { left: 10, right: 15, firstLine: 5, hanging: 8 }; - const result = normalizeParagraphIndent(input); - expect(result).toEqual({ left: 10, right: 15, firstLine: 5, hanging: 8 }); - }); - }); - - describe('twips conversion (> 50)', () => { - it('should convert values > 50 from twips to pixels', () => { - const input = { left: 720 }; // 0.5 inch = 720 twips = 48px - const result = normalizeParagraphIndent(input); - expect(result?.left).toBe(48); // 720 / 15 = 48 - }); - - it('should convert large twips values', () => { - const input = { left: 1440 }; // 1 inch = 1440 twips = 96px - const result = normalizeParagraphIndent(input); - expect(result?.left).toBe(96); - }); - - it('should convert all properties from twips when > 50', () => { - const input = { left: 720, right: 1440, firstLine: 360, hanging: 180 }; - const result = normalizeParagraphIndent(input); - expect(result?.left).toBe(48); - expect(result?.right).toBe(96); - expect(result?.firstLine).toBe(24); - expect(result?.hanging).toBe(12); - }); - - it('should handle negative twips values', () => { - const input = { firstLine: -720 }; - const result = normalizeParagraphIndent(input); - expect(result?.firstLine).toBe(-48); - }); - - it('should handle mixed pixel and twips values', () => { - const input = { left: 10, right: 720 }; // left is px, right is twips - const result = normalizeParagraphIndent(input); - expect(result?.left).toBe(10); - expect(result?.right).toBe(48); - }); - }); - - describe('boundary cases', () => { - it('should treat 50 as pixels (not twips)', () => { - const input = { left: 50 }; - const result = normalizeParagraphIndent(input); - expect(result?.left).toBe(50); - }); - - it('should treat 51 as twips and convert', () => { - const input = { left: 51 }; - const result = normalizeParagraphIndent(input); - expect(result?.left).toBe(3.4); // 51 / 15 = 3.4 - }); - - it('should treat -50 as pixels', () => { - const input = { left: -50 }; - const result = normalizeParagraphIndent(input); - expect(result?.left).toBe(-50); - }); - - it('should treat -51 as twips and convert', () => { - const input = { left: -51 }; - const result = normalizeParagraphIndent(input); - expect(result?.left).toBe(-3.4); - }); + it('accepts numeric strings', () => { + const result = getIndent({ left: '720' as never, right: '360' as never }); + expect(result?.left).toBe(twipsToPx(720)); + expect(result?.right).toBe(twipsToPx(360)); }); - describe('invalid inputs', () => { - it('should return undefined for null', () => { - expect(normalizeParagraphIndent(null)).toBeUndefined(); - }); - - it('should return undefined for undefined', () => { - expect(normalizeParagraphIndent(undefined)).toBeUndefined(); - }); - - it('should return undefined for non-object', () => { - expect(normalizeParagraphIndent('string')).toBeUndefined(); - expect(normalizeParagraphIndent(123)).toBeUndefined(); - }); - - it('should return undefined for empty object', () => { - expect(normalizeParagraphIndent({})).toBeUndefined(); - }); - - it('should skip non-numeric values', () => { - const input = { left: 10, right: 'not a number' }; - const result = normalizeParagraphIndent(input); - expect(result).toEqual({ left: 10 }); - }); - - it('should skip null values', () => { - const input = { left: 10, right: null }; - const result = normalizeParagraphIndent(input); - expect(result).toEqual({ left: 10 }); - }); - - it('should skip undefined values', () => { - const input = { left: 10, right: undefined }; - const result = normalizeParagraphIndent(input); - expect(result).toEqual({ left: 10 }); - }); + it('returns undefined for empty or invalid inputs', () => { + expect(getIndent(undefined)).toBeUndefined(); + expect(getIndent(null)).toBeUndefined(); + expect(getIndent({})).toBeUndefined(); }); - describe('fractional values', () => { - it('should handle fractional pixel values', () => { - const input = { left: 10.5 }; - const result = normalizeParagraphIndent(input); - expect(result?.left).toBe(10.5); - }); - - it('should handle fractional twips values', () => { - const input = { left: 720.5 }; - const result = normalizeParagraphIndent(input); - expect(result?.left).toBeCloseTo(48.03, 2); - }); + it('skips non-numeric values but preserves valid ones', () => { + const result = getIndent({ left: 720, right: 'nope' as never }); + expect(result?.left).toBe(twipsToPx(720)); + expect(result?.right).toBeUndefined(); }); }); diff --git a/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.ts b/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.ts index 1156f4caa9..81ee80b1ea 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.ts @@ -5,9 +5,9 @@ * and normalizing raw attributes. */ -import type { ParagraphAttrs, ParagraphIndent, ParagraphSpacing } from '@superdoc/contracts'; -import type { ComputedParagraphStyle, EngineParagraphSpacing, EngineParagraphIndent } from '../types.js'; -import { pxToPt, ptToPx, twipsToPx, pickNumber } from '../utilities.js'; +import type { ParagraphAttrs, ParagraphSpacing } from '@superdoc/contracts'; +import type { ParagraphSpacing as OoxmlParagraphSpacing } from '@superdoc/style-engine/ooxml'; +import { twipsToPx, pickNumber } from '../utilities.js'; /** * Maximum line spacing multiplier for auto line spacing. @@ -30,146 +30,6 @@ const MAX_AUTO_LINE_MULTIPLIER = 10; * 51-100 will be incorrectly converted from twips. This is a known limitation * of the heuristic approach used when the source format is ambiguous. */ -const TWIPS_THRESHOLD = 50; - -/** - * Converts paragraph spacing from pixels to points for the style engine. - * - * Transforms spacing values (before, after, line) from pixel measurements - * to point measurements while preserving the lineRule property. - * - * @param spacing - Paragraph spacing object with values in pixels - * @returns Spacing object with values in points for the style engine - * - * @example - * ```typescript - * spacingPxToPt({ before: 16, after: 16, line: 20 }); - * // { before: 12, after: 12, line: 15 } - * ``` - */ -export const spacingPxToPt = (spacing: ParagraphSpacing): ComputedParagraphStyle['spacing'] => { - const result: ComputedParagraphStyle['spacing'] = {}; - if (spacing.before != null) result.before = pxToPt(spacing.before); - if (spacing.after != null) result.after = pxToPt(spacing.after); - if (spacing.line != null) { - if (spacing.lineRule === 'auto' && spacing.line > 0 && spacing.line <= MAX_AUTO_LINE_MULTIPLIER) { - result.line = spacing.line; - } else { - result.line = pxToPt(spacing.line); - } - } - if (spacing.lineRule) result.lineRule = spacing.lineRule; - return result; -}; - -/** - * Converts paragraph indent from pixels to points for the style engine. - * - * Transforms indent values (left, right, firstLine, hanging) from pixel - * measurements to point measurements. - * - * @param indent - Paragraph indent object with values in pixels - * @returns Indent object with values in points for the style engine - * - * @example - * ```typescript - * indentPxToPt({ left: 48, firstLine: 24 }); - * // { left: 36, firstLine: 18 } - * ``` - */ -export const indentPxToPt = (indent: ParagraphIndent): ComputedParagraphStyle['indent'] => { - const result: ComputedParagraphStyle['indent'] = {}; - if (indent.left != null) result.left = pxToPt(indent.left); - if (indent.right != null) result.right = pxToPt(indent.right); - if (indent.firstLine != null) result.firstLine = pxToPt(indent.firstLine); - if (indent.hanging != null) result.hanging = pxToPt(indent.hanging); - return result; -}; - -/** - * Converts paragraph spacing from points to pixels. - * - * Uses the rawSpacing parameter to determine which properties to convert, - * only converting properties that exist in the raw spacing. - * - * @param spacing - Computed spacing from style engine with values in points - * @param rawSpacing - Original raw spacing to determine which properties to include - * @returns Spacing object with values in pixels, or undefined if rawSpacing is not provided or results in no properties - * - * @example - * ```typescript - * spacingPtToPx({ before: 12, after: 12 }, { before: 16, after: 16 }); - * // { before: 16, after: 16 } - * ``` - */ -export const spacingPtToPx = ( - spacing: EngineParagraphSpacing, - rawSpacing?: ParagraphSpacing, -): ParagraphSpacing | undefined => { - const result: ParagraphSpacing = {}; - if (rawSpacing) { - if (rawSpacing.before != null) { - const before = ptToPx(spacing.before); - if (before != null) result.before = before; - } - if (rawSpacing.after != null) { - const after = ptToPx(spacing.after); - if (after != null) result.after = after; - } - if (rawSpacing.line != null) { - const isAutoMultiplier = - spacing.lineRule === 'auto' && - spacing.line != null && - spacing.line > 0 && - spacing.line <= MAX_AUTO_LINE_MULTIPLIER; - if (isAutoMultiplier) { - result.line = spacing.line; - } else { - const line = ptToPx(spacing.line); - if (line != null) result.line = line; - } - if (spacing.lineRule) result.lineRule = spacing.lineRule; - } - } - return Object.keys(result).length > 0 ? result : undefined; -}; - -/** - * Converts paragraph indent from points to pixels. - * - * Transforms indent values from point measurements to pixel measurements. - * Preserves explicit zero values for firstLine and hanging since these are meaningful - * overrides (e.g., style setting firstLine=0 to override numbering level's firstLine). - * Filters out zero values for left/right to keep the result object minimal. - * - * @param indent - Computed indent from style engine with values in points - * @returns Indent object with values in pixels, or undefined if no values to preserve - * - * @example - * ```typescript - * indentPtToPx({ left: 36, firstLine: 18 }); - * // { left: 48, firstLine: 24 } - * - * // Zero firstLine is preserved (explicit override) - * indentPtToPx({ left: 0, firstLine: 0 }); - * // { firstLine: 0 } - * ``` - */ -export const indentPtToPx = (indent: EngineParagraphIndent): ParagraphIndent | undefined => { - const result: ParagraphIndent = {}; - const left = ptToPx(indent.left); - const right = ptToPx(indent.right); - const firstLine = ptToPx(indent.firstLine); - const hanging = ptToPx(indent.hanging); - // Filter out zero for left/right (purely cosmetic) - if (left != null && left !== 0) result.left = left; - if (right != null && right !== 0) result.right = right; - // Preserve zero for firstLine/hanging - these are meaningful overrides - // (e.g., style setting firstLine=0 to cancel numbering level's firstLine indent) - if (firstLine != null) result.firstLine = firstLine; - if (hanging != null) result.hanging = hanging; - return Object.keys(result).length > 0 ? result : undefined; -}; /** * Normalizes paragraph alignment values from OOXML format. @@ -192,9 +52,8 @@ export const indentPtToPx = (indent: EngineParagraphIndent): ParagraphIndent | u * normalizeAlignment('CENTER'); // undefined (case-sensitive) * ``` */ -type NormalizedParagraphAlignment = Exclude; -export const normalizeAlignment = (value: unknown): NormalizedParagraphAlignment => { +export const normalizeAlignment = (value: unknown): ParagraphAttrs['alignment'] => { switch (value) { case 'center': case 'right': @@ -236,51 +95,39 @@ export const normalizeAlignment = (value: unknown): NormalizedParagraphAlignment * // { before: 16, line: 32, lineRule: 'exact' } (line converted from twips) * ``` */ -type ExtendedParagraphSpacing = ParagraphSpacing & { contextualSpacing?: boolean }; - -export const normalizeParagraphSpacing = (value: unknown): ExtendedParagraphSpacing | undefined => { +export const normalizeParagraphSpacing = ( + value: OoxmlParagraphSpacing | undefined, + isList: boolean, +): ParagraphSpacing | undefined => { if (!value || typeof value !== 'object') return undefined; - const source = value as Record; - const spacing: ExtendedParagraphSpacing = {}; + const spacing: ParagraphSpacing = {}; + + let before = pickNumber(value.before); + let after = pickNumber(value.after); + const lineRaw = pickNumber(value.line); + const lineRule = normalizeLineRule(value.lineRule); + const beforeAutospacing = value.beforeAutospacing; + const afterAutospacing = value.afterAutospacing; - const beforeRaw = pickNumber(source.before); - const afterRaw = pickNumber(source.after); - const lineRaw = pickNumber(source.line); - const lineRule = normalizeLineRule(source.lineRule); - const resolvedLineRule = lineRule ?? (lineRaw != null ? 'auto' : undefined); - const beforeAutospacing = toBooleanFlag(source.beforeAutospacing ?? source.beforeAutoSpacing); - const afterAutospacing = toBooleanFlag(source.afterAutospacing ?? source.afterAutoSpacing); - const contextualSpacing = toBooleanFlag(source.contextualSpacing); + if (beforeAutospacing && isList) { + before = undefined; + } + if (afterAutospacing && isList) { + after = undefined; + } - const before = beforeRaw != null ? twipsToPx(beforeRaw) : pickNumber(source.lineSpaceBefore); - const after = afterRaw != null ? twipsToPx(afterRaw) : pickNumber(source.lineSpaceAfter); - const line = normalizeLineValue(lineRaw, resolvedLineRule); + const line = normalizeLineValue(lineRaw, lineRule); - if (before != null) spacing.before = before; - if (after != null) spacing.after = after; + if (before != null) spacing.before = twipsToPx(before); + if (after != null) spacing.after = twipsToPx(after); if (line != null) spacing.line = line; - if (resolvedLineRule) spacing.lineRule = resolvedLineRule; + if (lineRule != null) spacing.lineRule = lineRule; if (beforeAutospacing != null) spacing.beforeAutospacing = beforeAutospacing; if (afterAutospacing != null) spacing.afterAutospacing = afterAutospacing; - if (contextualSpacing != null) spacing.contextualSpacing = contextualSpacing; return Object.keys(spacing).length > 0 ? spacing : undefined; }; -const toBooleanFlag = (value: unknown): boolean | undefined => { - if (value === true || value === false) return value; - if (typeof value === 'string') { - const normalized = value.trim().toLowerCase(); - if (['true', '1', 'on', 'yes'].includes(normalized)) return true; - if (['false', '0', 'off', 'no'].includes(normalized)) return false; - } - if (typeof value === 'number') { - if (value === 1) return true; - if (value === 0) return false; - } - return undefined; -}; - const normalizeLineValue = ( value: number | undefined, lineRule: ParagraphSpacing['lineRule'] | undefined, @@ -316,108 +163,3 @@ export const normalizeLineRule = (value: unknown): ParagraphSpacing['lineRule'] } return undefined; }; - -/** - * Normalizes indent values that are already in pixels. - * - * Uses heuristics to detect if values are actually in twips (not pixels): - * - Values >= 50 are likely twips (50px would be ~667 twips in OOXML) - * - Values divisible by 15 with high precision are likely twips (common in OOXML) - * - * If values look like twips, returns undefined to trigger twips conversion instead. - * Epsilon of 1e-6 accounts for floating point arithmetic errors. - * - * @param value - Indent object with values assumed to be in pixels - * @returns Indent object if values are in pixels, or undefined if they look like twips - * - * @example - * ```typescript - * normalizePxIndent({ left: 24, firstLine: 12 }); - * // { left: 24, firstLine: 12 } (values look like pixels) - * - * normalizePxIndent({ left: 720, firstLine: 360 }); - * // undefined (values >= 50, likely twips) - * ``` - */ -export const normalizePxIndent = (value: unknown): ParagraphIndent | undefined => { - if (!value || typeof value !== 'object') return undefined; - const source = value as Record; - const indent: ParagraphIndent = {}; - const values: number[] = []; - (['left', 'right', 'firstLine', 'hanging'] as const).forEach((key) => { - const raw = source[key]; - if (typeof raw === 'number' && Number.isFinite(raw)) { - indent[key] = raw; - values.push(Math.abs(raw)); - } - }); - if (!values.length) return undefined; - - /** - * Heuristic for detecting twips values: - * 1. Values >= 50 are likely twips (50px = ~667 twips, invalid as px in OOXML context) - * 2. Non-zero twips values often divisible by 15 (e.g., half-point increments) - * - * Note: Zero is explicitly excluded from the divisibility check because it's a valid - * pixel value that happens to be divisible by 15. Zero indent is meaningful (explicit - * reset) and should not trigger twips conversion. - * - * Epsilon of 1e-6 accounts for floating point arithmetic errors. - */ - const looksLikeTwips = values.some((val) => val >= 50 || (val !== 0 && Math.abs(val % 15) < 1e-6)); - if (looksLikeTwips) { - return undefined; - } - return indent; -}; - -/** - * Normalizes paragraph indent from raw OOXML attributes, converting from twips if needed. - * - * Uses a threshold-based heuristic to detect the unit: - * - Values with absolute value <= 50 are treated as already-converted pixels - * - Values > 50 are treated as twips and converted to pixels - * - * Limitation: This creates an ambiguous zone where legitimate pixel values 51-100 - * will be incorrectly converted from twips. This is a known limitation of the - * heuristic approach when source format is ambiguous. - * - * @param value - Raw OOXML indent object with properties like left, right, firstLine, hanging - * @returns Normalized indent object with values in pixels, or undefined if no valid indent - * - * @example - * ```typescript - * normalizeParagraphIndent({ left: 720, firstLine: 360 }); - * // { left: 48, firstLine: 24 } (converted from twips) - * - * normalizeParagraphIndent({ left: 24, firstLine: 12 }); - * // { left: 24, firstLine: 12 } (treated as pixels) - * ``` - */ -export const normalizeParagraphIndent = (value: unknown): ParagraphIndent | undefined => { - if (!value || typeof value !== 'object') return undefined; - const source = value as Record; - const indent: ParagraphIndent = {}; - - const convert = (value?: number): number | undefined => { - const num = pickNumber(value); - if (num == null) return undefined; - // Treat small values as already-converted px (SuperDoc stores px in PM attrs) - if (Math.abs(num) <= TWIPS_THRESHOLD) { - return num; - } - return twipsToPx(Number(num)); - }; - - const left = convert(pickNumber(source.left)); - const right = convert(pickNumber(source.right)); - const firstLine = convert(pickNumber(source.firstLine)); - const hanging = convert(pickNumber(source.hanging)); - - if (left != null) indent.left = left; - if (right != null) indent.right = right; - if (firstLine != null) indent.firstLine = firstLine; - if (hanging != null) indent.hanging = hanging; - - return Object.keys(indent).length > 0 ? indent : undefined; -}; diff --git a/packages/layout-engine/pm-adapter/src/attributes/tabs.ts b/packages/layout-engine/pm-adapter/src/attributes/tabs.ts index 69852ba680..33be9e5326 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/tabs.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/tabs.ts @@ -198,6 +198,7 @@ export const normalizeTabVal = (value: unknown): TabStop['val'] | undefined => { case 'clear': return value; case 'left': + case 'num': return 'start'; // Legacy mapping for RTL support case 'right': return 'end'; // Legacy mapping for RTL support diff --git a/packages/layout-engine/pm-adapter/src/converter-context.ts b/packages/layout-engine/pm-adapter/src/converter-context.ts index 1b779201ed..4fdd7b7275 100644 --- a/packages/layout-engine/pm-adapter/src/converter-context.ts +++ b/packages/layout-engine/pm-adapter/src/converter-context.ts @@ -9,6 +9,7 @@ */ import type { ParagraphSpacing } from '@superdoc/contracts'; +import type { NumberingProperties, StylesDocumentProperties, TableInfo } from '@superdoc/style-engine/ooxml'; export type ConverterNumberingContext = { definitions?: Record; @@ -35,6 +36,8 @@ export type ConverterContext = { docx?: Record; numbering?: ConverterNumberingContext; linkedStyles?: ConverterLinkedStyle[]; + translatedNumbering: NumberingProperties; + translatedLinkedStyles: StylesDocumentProperties; /** * Optional mapping from OOXML footnote id -> display number. * Display numbers are assigned in order of first appearance in the document (1-based), @@ -49,7 +52,7 @@ export type ConverterContext = { * * Style cascade: docDefaults → tableStyleParagraphProps → paragraph style → direct formatting */ - tableStyleParagraphProps?: TableStyleParagraphProps; + tableInfo?: TableInfo; /** * Background color of the containing table cell (hex format, e.g., "#342D8C"). * Used for auto text color resolution - text without explicit color should diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts index 93a1036e56..06690f04cc 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts @@ -23,6 +23,7 @@ import type { HyperlinkConfig, StyleContext, } from '../types.js'; +import type { ConverterContext } from '../converter-context.js'; import type { Run, TextRun, FlowBlock, ParagraphBlock, TrackedChangeMeta, ImageRun } from '@superdoc/contracts'; // Mock external dependencies @@ -37,6 +38,7 @@ vi.mock('../attributes/index.js', () => ({ cloneParagraphAttrs: vi.fn(), hasPageBreakBefore: vi.fn(), buildStyleNodeFromAttrs: vi.fn(() => ({})), + deepClone: vi.fn((value) => value), normalizeParagraphSpacing: vi.fn(), normalizeParagraphIndent: vi.fn(), normalizePxIndent: vi.fn(), @@ -61,14 +63,14 @@ vi.mock('../tracked-changes.js', () => ({ })); vi.mock('../attributes/paragraph-styles.js', () => ({ - hydrateParagraphStyleAttrs: vi.fn(), + resolveParagraphProperties: vi.fn(), hydrateCharacterStyleAttrs: vi.fn(), hydrateMarkerStyleAttrs: vi.fn(), })); // Import mocked functions import { textNodeToRun, tabNodeToRun, tokenNodeToRun } from './text-run.js'; -import { computeParagraphAttrs, cloneParagraphAttrs, hasPageBreakBefore } from '../attributes/index.js'; +import { computeParagraphAttrs, cloneParagraphAttrs, deepClone, hasPageBreakBefore } from '../attributes/index.js'; import { resolveNodeSdtMetadata, getNodeInstruction } from '../sdt/index.js'; import { trackedChangesCompatible, collectTrackedChangeFromMarks, applyMarksToRun } from '../marks/index.js'; import { @@ -600,6 +602,7 @@ describe('paragraph converters', () => { let nextBlockId: BlockIdGenerator; let positions: PositionMap; let styleContext: StyleContext; + let converterContext: ConverterContext; beforeEach(() => { vi.clearAllMocks(); @@ -613,9 +616,19 @@ describe('paragraph converters', () => { // Setup style context (mock) styleContext = {}; + converterContext = { + translatedNumbering: {}, + translatedLinkedStyles: { + docDefaults: { + runProperties: {}, + paragraphProperties: {}, + }, + styles: {}, + }, + }; // Setup default mock returns - vi.mocked(computeParagraphAttrs).mockReturnValue({}); + vi.mocked(computeParagraphAttrs).mockReturnValue({ paragraphAttrs: {}, resolvedParagraphProperties: {} }); vi.mocked(cloneParagraphAttrs).mockReturnValue({}); vi.mocked(hasPageBreakBefore).mockReturnValue(false); vi.mocked(textNodeToRun).mockImplementation((node) => ({ @@ -767,13 +780,16 @@ describe('paragraph converters', () => { ); }); - it('should add page break before paragraph when hasPageBreakBefore returns true', () => { + it('should add page break before paragraph when paragraph attrs request it', () => { const para: PMNode = { type: 'paragraph', content: [{ type: 'text', text: 'Test' }], }; - vi.mocked(hasPageBreakBefore).mockReturnValue(true); + vi.mocked(computeParagraphAttrs).mockReturnValue({ + paragraphAttrs: { pageBreakBefore: true }, + resolvedParagraphProperties: {}, + }); const blocks = paragraphToFlowBlocks(para, nextBlockId, positions, 'Arial', 16, styleContext); @@ -818,7 +834,20 @@ describe('paragraph converters', () => { ], }; - const blocks = paragraphToFlowBlocks(para, nextBlockId, positions, 'Arial', 16, styleContext); + const blocks = paragraphToFlowBlocks( + para, + nextBlockId, + positions, + 'Arial', + 16, + styleContext, + undefined, + undefined, + undefined, + undefined, + undefined, + converterContext, + ); expect(blocks).toHaveLength(1); // textNodeToRun receives empty marks - marks are applied separately after linked styles @@ -860,7 +889,20 @@ describe('paragraph converters', () => { ], }; - const blocks = paragraphToFlowBlocks(para, nextBlockId, positions, 'Arial', 16, styleContext); + const blocks = paragraphToFlowBlocks( + para, + nextBlockId, + positions, + 'Arial', + 16, + styleContext, + undefined, + undefined, + undefined, + undefined, + undefined, + converterContext, + ); expect(blocks).toHaveLength(0); expect(vi.mocked(textNodeToRun)).not.toHaveBeenCalled(); @@ -884,7 +926,20 @@ describe('paragraph converters', () => { ], }; - paragraphToFlowBlocks(para, nextBlockId, positions, 'Arial', 16, styleContext); + paragraphToFlowBlocks( + para, + nextBlockId, + positions, + 'Arial', + 16, + styleContext, + undefined, + undefined, + undefined, + undefined, + undefined, + converterContext, + ); // textNodeToRun receives empty marks - marks are applied separately after linked styles expect(vi.mocked(textNodeToRun)).toHaveBeenCalledWith( @@ -930,7 +985,7 @@ describe('paragraph converters', () => { const blocks = paragraphToFlowBlocks(para, nextBlockId, positions, 'Arial', 16, styleContext); - expect(vi.mocked(tabNodeToRun)).toHaveBeenCalledWith(tabNode, positions, 0, para, []); + expect(vi.mocked(tabNodeToRun)).toHaveBeenCalledWith(tabNode, positions, 0, {}, []); const paraBlock = blocks[0] as ParagraphBlock; expect(paraBlock.runs).toContain(mockTabRun); }); @@ -943,9 +998,9 @@ describe('paragraph converters', () => { paragraphToFlowBlocks(para, nextBlockId, positions, 'Arial', 16, styleContext); - expect(vi.mocked(tabNodeToRun)).toHaveBeenNthCalledWith(1, expect.any(Object), positions, 0, para, []); - expect(vi.mocked(tabNodeToRun)).toHaveBeenNthCalledWith(2, expect.any(Object), positions, 1, para, []); - expect(vi.mocked(tabNodeToRun)).toHaveBeenNthCalledWith(3, expect.any(Object), positions, 2, para, []); + expect(vi.mocked(tabNodeToRun)).toHaveBeenNthCalledWith(1, expect.any(Object), positions, 0, {}, []); + expect(vi.mocked(tabNodeToRun)).toHaveBeenNthCalledWith(2, expect.any(Object), positions, 1, {}, []); + expect(vi.mocked(tabNodeToRun)).toHaveBeenNthCalledWith(3, expect.any(Object), positions, 2, {}, []); }); it('should skip tab when tabNodeToRun returns null', () => { @@ -1277,7 +1332,20 @@ describe('paragraph converters', () => { vi.mocked(getNodeInstruction).mockReturnValue('PAGEREF _Toc123 \\h'); positions.set(pageRefNode, { start: 10, end: 15 }); - const blocks = paragraphToFlowBlocks(para, nextBlockId, positions, 'Arial', 16, styleContext); + const blocks = paragraphToFlowBlocks( + para, + nextBlockId, + positions, + 'Arial', + 16, + styleContext, + undefined, + undefined, + undefined, + undefined, + undefined, + converterContext, + ); const paraBlock = blocks[0] as ParagraphBlock; const run = paraBlock.runs[0] as TextRun; @@ -1298,7 +1366,20 @@ describe('paragraph converters', () => { vi.mocked(getNodeInstruction).mockReturnValue('PAGEREF "_Toc456" \\h'); - const blocks = paragraphToFlowBlocks(para, nextBlockId, positions, 'Arial', 16, styleContext); + const blocks = paragraphToFlowBlocks( + para, + nextBlockId, + positions, + 'Arial', + 16, + styleContext, + undefined, + undefined, + undefined, + undefined, + undefined, + converterContext, + ); const paraBlock = blocks[0] as ParagraphBlock; const run = paraBlock.runs[0] as TextRun; @@ -1324,6 +1405,12 @@ describe('paragraph converters', () => { 'Arial', 16, styleContext, + undefined, + undefined, + undefined, + undefined, + undefined, + converterContext, ); expect(vi.mocked(textNodeToRun)).toHaveBeenCalledWith( @@ -1351,6 +1438,12 @@ describe('paragraph converters', () => { 'Arial', 16, styleContext, + undefined, + undefined, + undefined, + undefined, + undefined, + converterContext, ); expect(vi.mocked(textNodeToRun)).toHaveBeenCalledWith( @@ -1384,6 +1477,12 @@ describe('paragraph converters', () => { 'Arial', 16, styleContext, + undefined, + undefined, + undefined, + undefined, + undefined, + converterContext, ); expect(vi.mocked(textNodeToRun)).toHaveBeenCalledWith( @@ -1418,6 +1517,12 @@ describe('paragraph converters', () => { 'Arial', 16, styleContext, + undefined, + undefined, + undefined, + undefined, + undefined, + converterContext, ); // textNodeToRun is called with empty marks (marks are applied separately via applyMarksToRun) @@ -1458,7 +1563,7 @@ describe('paragraph converters', () => { positions.set(bookmarkNode, { start: 100, end: 100 }); const bookmarks = new Map(); - paragraphToFlowBlocks(para, nextBlockId, positions, 'Arial', 16, styleContext, undefined, undefined, bookmarks); + paragraphToFlowBlocks(para, nextBlockId, positions, 'Arial', 16, styleContext, undefined, bookmarks); expect(bookmarks.get('MyBookmark')).toBe(100); }); @@ -1535,7 +1640,6 @@ describe('paragraph converters', () => { undefined, undefined, undefined, - undefined, converters as never, ); @@ -1581,7 +1685,6 @@ describe('paragraph converters', () => { 'Arial', 16, styleContext, - undefined, trackedChanges, undefined, undefined, @@ -1617,7 +1720,6 @@ describe('paragraph converters', () => { 'Arial', 16, styleContext, - undefined, { mode: 'final', enabled: true }, undefined, undefined, @@ -1657,7 +1759,6 @@ describe('paragraph converters', () => { undefined, undefined, undefined, - undefined, converters as never, ); @@ -1691,7 +1792,6 @@ describe('paragraph converters', () => { undefined, undefined, undefined, - undefined, converters as never, ); @@ -1724,7 +1824,6 @@ describe('paragraph converters', () => { undefined, undefined, undefined, - undefined, converters as never, ); @@ -1757,7 +1856,6 @@ describe('paragraph converters', () => { undefined, undefined, undefined, - undefined, converters as never, ); @@ -1793,7 +1891,6 @@ describe('paragraph converters', () => { 'Arial', 16, styleContext, - undefined, trackedChanges, bookmarks, hyperlinkConfig, @@ -1811,7 +1908,7 @@ describe('paragraph converters', () => { trackedChanges, bookmarks, hyperlinkConfig, - undefined, // converterContext parameter added + undefined, ); expect(blocks.some((b) => b.kind === 'table')).toBe(true); }); @@ -1897,11 +1994,9 @@ describe('paragraph converters', () => { 'Arial', 16, styleContext, - undefined, trackedChanges, undefined, undefined, - undefined, ); expect(vi.mocked(applyTrackedChangesModeToRuns)).toHaveBeenCalledWith( @@ -1931,16 +2026,7 @@ describe('paragraph converters', () => { vi.mocked(applyTrackedChangesModeToRuns).mockReturnValue([]); - const blocks = paragraphToFlowBlocks( - para, - nextBlockId, - positions, - 'Arial', - 16, - styleContext, - undefined, - trackedChanges, - ); + const blocks = paragraphToFlowBlocks(para, nextBlockId, positions, 'Arial', 16, styleContext, trackedChanges); expect(blocks).toHaveLength(0); }); @@ -1970,16 +2056,7 @@ describe('paragraph converters', () => { vi.mocked(applyTrackedChangesModeToRuns).mockReturnValue([]); - const blocks = paragraphToFlowBlocks( - para, - nextBlockId, - positions, - 'Arial', - 16, - styleContext, - undefined, - trackedChanges, - ); + const blocks = paragraphToFlowBlocks(para, nextBlockId, positions, 'Arial', 16, styleContext, trackedChanges); expect(blocks.some((b) => b.kind === 'pageBreak')).toBe(true); }); @@ -2167,7 +2244,6 @@ describe('paragraph converters', () => { styleContext, undefined, undefined, - undefined, customHyperlinkConfig, ); @@ -2183,27 +2259,28 @@ describe('paragraph converters', () => { ); }); - it('should pass list counter context to computeParagraphAttrs', () => { + it('should pass converter context to computeParagraphAttrs', () => { const para: PMNode = { type: 'paragraph', content: [{ type: 'text', text: 'Test' }], }; - const listCounterContext = { - getListCounter: vi.fn(), - incrementListCounter: vi.fn(), - resetListCounter: vi.fn(), - }; - - paragraphToFlowBlocks(para, nextBlockId, positions, 'Arial', 16, styleContext, listCounterContext); - - expect(vi.mocked(computeParagraphAttrs)).toHaveBeenCalledWith( + paragraphToFlowBlocks( para, + nextBlockId, + positions, + 'Arial', + 16, styleContext, - listCounterContext, - undefined, // converterContext parameter - null, // paragraphHydration parameter + undefined, + undefined, + undefined, + undefined, + undefined, + converterContext, ); + + expect(vi.mocked(computeParagraphAttrs)).toHaveBeenCalledWith(para, converterContext); }); it('should clone paragraph attrs for each paragraph block', () => { @@ -2217,14 +2294,17 @@ describe('paragraph converters', () => { }; const mockAttrs = { align: 'center' }; - vi.mocked(computeParagraphAttrs).mockReturnValue(mockAttrs); - vi.mocked(cloneParagraphAttrs).mockImplementation((attrs) => ({ ...attrs })); + vi.mocked(computeParagraphAttrs).mockReturnValue({ + paragraphAttrs: mockAttrs, + resolvedParagraphProperties: {}, + }); + vi.mocked(deepClone).mockImplementation((attrs) => ({ ...attrs })); const blocks = paragraphToFlowBlocks(para, nextBlockId, positions, 'Arial', 16, styleContext); const paraBlocks = blocks.filter((b) => b.kind === 'paragraph'); // Should be called once per paragraph block (2 blocks in this case) - expect(vi.mocked(cloneParagraphAttrs)).toHaveBeenCalledTimes(paraBlocks.length); + expect(vi.mocked(deepClone)).toHaveBeenCalledTimes(paraBlocks.length); }); }); }); @@ -2817,7 +2897,7 @@ describe('paragraph converters', () => { positions = new WeakMap(); styleContext = {}; - vi.mocked(computeParagraphAttrs).mockReturnValue({}); + vi.mocked(computeParagraphAttrs).mockReturnValue({ paragraphAttrs: {}, resolvedParagraphProperties: {} }); vi.mocked(cloneParagraphAttrs).mockReturnValue({}); vi.mocked(hasPageBreakBefore).mockReturnValue(false); vi.mocked(textNodeToRun).mockImplementation((node) => ({ @@ -2854,7 +2934,6 @@ describe('paragraph converters', () => { undefined, undefined, undefined, - undefined, converters as never, ); @@ -2909,7 +2988,6 @@ describe('paragraph converters', () => { undefined, undefined, undefined, - undefined, converters as never, ); diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts index 8538e783d7..d12913a3f2 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts @@ -7,6 +7,7 @@ * - Tracked changes processing */ +import type { ParagraphProperties, RunProperties } from '@superdoc/style-engine/ooxml'; import type { FlowBlock, Run, @@ -15,8 +16,6 @@ import type { ImageBlock, TrackedChangeMeta, SdtMetadata, - ParagraphAttrs, - ParagraphIndent, FieldAnnotationRun, FieldAnnotationMetadata, } from '@superdoc/contracts'; @@ -26,24 +25,13 @@ import type { BlockIdGenerator, PositionMap, StyleContext, - ListCounterContext, TrackedChangesConfig, HyperlinkConfig, NodeHandlerContext, ThemeColorPalette, } from '../types.js'; import type { ConverterContext } from '../converter-context.js'; -import { - computeParagraphAttrs, - cloneParagraphAttrs, - hasPageBreakBefore, - buildStyleNodeFromAttrs, - normalizeParagraphSpacing, - normalizeParagraphIndent, - normalizePxIndent, - normalizeOoxmlTabs, -} from '../attributes/index.js'; -import { hydrateParagraphStyleAttrs, hydrateCharacterStyleAttrs } from '../attributes/paragraph-styles.js'; +import { computeParagraphAttrs, deepClone } from '../attributes/index.js'; import { resolveNodeSdtMetadata, getNodeInstruction } from '../sdt/index.js'; import { shouldRequirePageBoundary, hasIntrinsicBoundarySignals, createSectionBreakBlock } from '../sections/index.js'; import { trackedChangesCompatible, collectTrackedChangeFromMarks, applyMarksToRun } from '../marks/index.js'; @@ -55,23 +43,9 @@ import { import { textNodeToRun, tabNodeToRun, tokenNodeToRun } from './text-run.js'; import { contentBlockNodeToDrawingBlock } from './content-block.js'; import { DEFAULT_HYPERLINK_CONFIG, TOKEN_INLINE_TYPES } from '../constants.js'; -import { createLinkedStyleResolver, applyLinkedStyleToRun, extractRunStyleId } from '../styles/linked-run.js'; -import { - ptToPx, - pickNumber, - isPlainObject, - convertIndentTwipsToPx, - twipsToPx, - toBoolean, - asOoxmlElement, - findOoxmlChild, - getOoxmlAttribute, - parseOoxmlNumber, - type OoxmlElement, -} from '../utilities.js'; -import { resolveStyle } from '@superdoc/style-engine'; -import { resolveDocxFontFamily } from '@superdoc/style-engine/ooxml'; -import { SuperConverter } from '@superdoc/super-editor/converter/internal/SuperConverter.js'; +import { ptToPx, pickNumber, isPlainObject, twipsToPx } from '../utilities.js'; +import { computeRunAttrs } from '../attributes/paragraph.js'; +import { resolveRunProperties } from '@superdoc/style-engine/ooxml'; // ============================================================================ // Constants @@ -83,24 +57,6 @@ import { SuperConverter } from '@superdoc/super-editor/converter/internal/SuperC */ const DEFAULT_IMAGE_DIMENSION_PX = 100; -/** - * Conversion constant: OOXML font sizes are stored in half-points. - * To convert to full points: divide by 2. - */ -const HALF_POINTS_PER_POINT = 2; - -/** - * Screen DPI (dots per inch) for pixel conversions. - * Standard display density is 96 DPI. - */ -const SCREEN_DPI = 96; - -/** - * Point DPI (dots per inch) for typography. - * Standard typography uses 72 DPI (1 inch = 72 points). - */ -const POINT_DPI = 72; - // ============================================================================ // Helper functions for inline image detection and conversion // ============================================================================ @@ -173,7 +129,7 @@ export function isInlineImage(node: PMNode): boolean { const isNodeHidden = (node: PMNode): boolean => { const attrs = (node.attrs ?? {}) as Record; - if (toBoolean(attrs.hidden) === true) return true; + if (attrs.hidden === true) return true; return typeof attrs.visibility === 'string' && attrs.visibility.toLowerCase() === 'hidden'; }; @@ -540,304 +496,16 @@ export function mergeAdjacentRuns(runs: Run[]): Run[] { return merged; } -type RunDefaults = { - fontFamily?: string; - fontSizePx?: number; - color?: string; - bold?: boolean; - italic?: boolean; - underline?: TextRun['underline']; - letterSpacing?: number; -}; - -/** - * Extracts font properties from the first text node in a paragraph's content. - * This is used to match list marker font to the paragraph's first text run. - * - * @param para - The paragraph PM node - * @returns Font properties (fontSizePx already in pixels, fontFamily) or undefined if not found - */ -const extractFirstTextRunFont = (para: PMNode): { fontSizePx?: number; fontFamily?: string } | undefined => { - if (!para.content || !Array.isArray(para.content) || para.content.length === 0) { - return undefined; - } - - // Helper to find fontSize mark and extract value - const extractFontFromMarks = (marks?: PMMark[]): { fontSizePx?: number; fontFamily?: string } | undefined => { - if (!marks || !Array.isArray(marks)) return undefined; - - const result: { fontSizePx?: number; fontFamily?: string } = {}; - - for (const mark of marks) { - if (!mark || typeof mark !== 'object') continue; - - // Look for textStyle mark which contains font info - if (mark.type === 'textStyle' && mark.attrs) { - const attrs = mark.attrs as Record; - // fontSize is stored as a string with unit, e.g., '12pt' or '16px' - if (attrs.fontSize != null) { - const fontSizeStr = String(attrs.fontSize); - const size = parseFloat(fontSizeStr); - if (Number.isFinite(size)) { - // Check the unit - only convert if it's in points - if (fontSizeStr.endsWith('pt')) { - result.fontSizePx = ptToPx(size); - } else { - // px or unitless - already in pixels - result.fontSizePx = size; - } - } - } - if (typeof attrs.fontFamily === 'string') { - result.fontFamily = attrs.fontFamily; - } - } - } - - return Object.keys(result).length > 0 ? result : undefined; - }; - - // Recursively find first text node - const findFirstTextFont = (nodes: PMNode[]): { fontSizePx?: number; fontFamily?: string } | undefined => { - for (const node of nodes) { - if (!node) continue; - - // If it's a text node, check its marks - if (node.type === 'text') { - const font = extractFontFromMarks(node.marks); - if (font) return font; - } - - // If it's a run node, check its content - if (node.type === 'run' && Array.isArray(node.content)) { - // First check the run's own marks - const runFont = extractFontFromMarks(node.marks); - // Then check children - const childFont = findFirstTextFont(node.content); - // Merge: child takes precedence for fontSizePx - if (runFont || childFont) { - return { - fontSizePx: childFont?.fontSizePx ?? runFont?.fontSizePx, - fontFamily: childFont?.fontFamily ?? runFont?.fontFamily, - }; - } - } - - // Handle other container nodes - if (Array.isArray(node.content)) { - const font = findFirstTextFont(node.content); - if (font) return font; - } - } - return undefined; - }; - - const font = findFirstTextFont(para.content); - return font; -}; - -/** - * Resolves a font family value to a CSS-compatible font family string. - * - * Handles both simple string font families and complex OOXML font family objects - * that may include theme fonts, different scripts (ascii, hAnsi, eastAsia, cs). - * - * @param fontFamily - The font family value (string or OOXML font family object) - * @param docx - Optional docx context for theme font resolution - * @returns Resolved CSS font family string, or undefined if resolution fails - * - * @example - * ```typescript - * resolveRunFontFamily('Arial'); // 'Arial' - * resolveRunFontFamily({ ascii: 'Calibri', hAnsi: 'Calibri' }, docx); // 'Calibri' - * ``` - */ -const resolveRunFontFamily = (fontFamily: unknown, docx?: Record): string | undefined => { - if (typeof fontFamily === 'string' && fontFamily.trim().length > 0) { - return fontFamily; - } - if (!fontFamily || typeof fontFamily !== 'object') return undefined; - const toCssFontFamily = ( - SuperConverter as { toCssFontFamily?: (fontName: string, docx?: Record) => string } - ).toCssFontFamily; - const resolved = resolveDocxFontFamily(fontFamily as Record, docx ?? null, toCssFontFamily); - return resolved ?? undefined; -}; - -/** - * Parses a font size value to pixels. - * - * Handles multiple input formats: - * - Raw number: interpreted as half-points (OOXML format) - * - String ending in 'pt': interpreted as points - * - String ending in 'px': returned as-is - * - String without suffix: interpreted as half-points - * - * @param fontSize - The font size value to parse - * @returns Font size in pixels, or undefined if parsing fails - * - * @example - * ```typescript - * parseRunFontSizePx(24); // 16 (24 half-points = 12pt = 16px) - * parseRunFontSizePx('12pt'); // 16 - * parseRunFontSizePx('16px'); // 16 - * ``` - */ -const parseRunFontSizePx = (fontSize: unknown): number | undefined => { - if (typeof fontSize === 'number' && Number.isFinite(fontSize)) { - return ptToPx(fontSize / HALF_POINTS_PER_POINT) ?? undefined; - } - if (typeof fontSize === 'string') { - const numeric = Number.parseFloat(fontSize); - if (!Number.isFinite(numeric)) return undefined; - if (fontSize.endsWith('pt')) { - return ptToPx(numeric); - } - if (fontSize.endsWith('px')) { - return numeric; - } - return ptToPx(numeric / HALF_POINTS_PER_POINT) ?? undefined; - } - return undefined; -}; - -/** - * Extracts run properties from paragraph mark (w:pPr/w:rPr) in OOXML. - * - * The paragraph mark in Word has its own run properties that apply to empty - * paragraphs or the paragraph mark character itself. This function extracts - * font size and font family from these properties. - * - * @param paragraphProps - The paragraph properties object - * @returns Extracted run properties (fontSize, fontFamily), or undefined if none found - * - * @example - * ```typescript - * extractParagraphMarkRunProps({ - * runProperties: { fontSize: 24 } - * }); // { fontSize: 24 } - * ``` - */ -const extractParagraphMarkRunProps = (paragraphProps: Record): Record | undefined => { - const directRunProps = paragraphProps.runProperties; - const directRunPropsElement = asOoxmlElement(directRunProps); - if (directRunProps && isPlainObject(directRunProps) && !directRunPropsElement) { - return directRunProps as Record; - } - - const element = asOoxmlElement(paragraphProps); - const pPr = element ? (element.name === 'w:pPr' ? element : findOoxmlChild(element, 'w:pPr')) : undefined; - const rPr = directRunPropsElement?.name === 'w:rPr' ? directRunPropsElement : findOoxmlChild(pPr, 'w:rPr'); - if (!rPr) return undefined; - - const runProps: Record = {}; - const sz = - parseOoxmlNumber(getOoxmlAttribute(findOoxmlChild(rPr, 'w:sz'), 'w:val')) ?? - parseOoxmlNumber(getOoxmlAttribute(findOoxmlChild(rPr, 'w:szCs'), 'w:val')); - if (sz != null) { - runProps.fontSize = sz; - } - - const rFonts = findOoxmlChild(rPr, 'w:rFonts'); - if (rFonts) { - const fontFamily: Record = {}; - const keys = ['ascii', 'hAnsi', 'eastAsia', 'cs', 'val', 'asciiTheme', 'hAnsiTheme', 'eastAsiaTheme', 'cstheme']; - for (const key of keys) { - const value = getOoxmlAttribute(rFonts, `w:${key}`); - if (value != null) { - fontFamily[key] = value; - } - } - if (Object.keys(fontFamily).length > 0) { - runProps.fontFamily = fontFamily; - } - } - - return Object.keys(runProps).length > 0 ? runProps : undefined; -}; - -/** - * Applies paragraph mark run properties to an empty paragraph's text run. - * - * In Word, empty paragraphs inherit their appearance from the paragraph mark's - * run properties (w:pPr/w:rPr). This function applies those properties to ensure - * empty paragraphs render with the correct font size and family. - * - * @param run - The text run to apply properties to - * @param paragraphProps - The paragraph properties containing run properties - * @param converterContext - Optional converter context for font resolution - * - * @example - * ```typescript - * const run: TextRun = { text: '' }; - * applyParagraphMarkRunProps(run, paragraphProps, context); - * // run.fontSize and run.fontFamily may now be set - * ``` - */ -const applyParagraphMarkRunProps = ( - run: TextRun, - paragraphProps: Record, - converterContext?: ConverterContext, -): void => { - const runProps = extractParagraphMarkRunProps(paragraphProps); - if (!runProps) return; - const fontSizePx = parseRunFontSizePx(runProps.fontSize); - if (fontSizePx != null) { - run.fontSize = fontSizePx; - } - const fontFamily = resolveRunFontFamily(runProps.fontFamily, converterContext?.docx); - if (fontFamily) { - run.fontFamily = fontFamily; - } -}; - -const applyBaseRunDefaults = ( - run: TextRun, - defaults: RunDefaults, - uiDisplayFallbackFont: string, - fallbackSize: number, -): void => { - if (!run) return; - if (defaults.fontFamily && run.fontFamily === uiDisplayFallbackFont) { - run.fontFamily = defaults.fontFamily; - } - if (defaults.fontSizePx != null && run.fontSize === fallbackSize) { - run.fontSize = defaults.fontSizePx; - } - if (defaults.color && !run.color) { - run.color = defaults.color; - } - if (defaults.letterSpacing != null && run.letterSpacing == null) { - run.letterSpacing = defaults.letterSpacing; - } - // NOTE: We intentionally do NOT apply bold, italic, or underline from baseRunDefaults. - // These properties come from the paragraph's default character style (e.g., Heading 1's bold), - // but should NOT be applied to runs that have their own character styles or marks. - // Bold/italic/underline should only come from: - // 1. Linked character styles (via applyRunStyles) - // 2. Inline marks (via applyMarksToRun) - // Applying paragraph-level character defaults here causes incorrect bolding of normal text - // in paragraphs with bold styles like Heading 1. -}; - const applyInlineRunProperties = ( run: TextRun, - runProperties: (Record & { letterSpacing?: number | null }) | null | undefined, -): void => { - if (!runProperties) return; - if (runProperties?.letterSpacing != null) { - run.letterSpacing = twipsToPx(runProperties.letterSpacing); - } -}; - -const getVanishValue = (runProperties: unknown): boolean | undefined => { - if (!runProperties || typeof runProperties !== 'object' || Array.isArray(runProperties)) { - return undefined; - } - if (!Object.prototype.hasOwnProperty.call(runProperties, 'vanish')) { - return undefined; + runProperties: RunProperties | undefined, + converterContext?: ConverterContext, +): TextRun => { + if (!runProperties) { + return run; } - return (runProperties as Record).vanish === true; + const runAttrs = computeRunAttrs(runProperties, converterContext); + return { ...run, ...runAttrs }; }; /** @@ -856,7 +524,6 @@ const getVanishValue = (runProperties: unknown): boolean | undefined => { * @param defaultFont - Default font family * @param defaultSize - Default font size * @param styleContext - Style resolution context - * @param listCounterContext - Optional list counter context * @param trackedChanges - Optional tracked changes configuration * @param bookmarks - Optional bookmark position map * @param hyperlinkConfig - Hyperlink configuration @@ -873,7 +540,6 @@ export function paragraphToFlowBlocks( defaultFont: string, defaultSize: number, styleContext: StyleContext, - listCounterContext?: ListCounterContext, trackedChanges?: TrackedChangesConfig, bookmarks?: Map, hyperlinkConfig: HyperlinkConfig = DEFAULT_HYPERLINK_CONFIG, @@ -929,150 +595,13 @@ export function paragraphToFlowBlocks( converterContext?: ConverterContext, enableComments = true, ): FlowBlock[] { - const baseBlockId = nextBlockId('paragraph'); const paragraphProps = typeof para.attrs?.paragraphProperties === 'object' && para.attrs.paragraphProperties !== null - ? (para.attrs.paragraphProperties as Record) + ? (para.attrs.paragraphProperties as ParagraphProperties) : {}; - const paragraphHiddenByVanish = getVanishValue(paragraphProps.runProperties) === true; - const paragraphStyleId = - typeof para.attrs?.styleId === 'string' && para.attrs.styleId.trim() - ? para.attrs.styleId - : typeof paragraphProps.styleId === 'string' && paragraphProps.styleId.trim() - ? (paragraphProps.styleId as string) - : null; - const paragraphHydration = converterContext ? hydrateParagraphStyleAttrs(para, converterContext) : null; - - let baseRunDefaults: RunDefaults = {}; - try { - // Try to get character defaults from the correct OOXML cascade via styles.js - // This includes w:rPrDefault from w:docDefaults, which resolveStyle() ignores - const charHydration = converterContext - ? hydrateCharacterStyleAttrs(para, converterContext, paragraphHydration?.resolved as Record) - : null; - - if (charHydration) { - // Use correctly cascaded character properties from styles.js - // Font size is in half-points, convert to pixels: halfPts / 2 = pts, pts * (96/72) = px - const fontSizePx = (charHydration.fontSize / HALF_POINTS_PER_POINT) * (SCREEN_DPI / POINT_DPI); - baseRunDefaults = { - fontFamily: charHydration.fontFamily, - fontSizePx, - color: charHydration.color ? `#${charHydration.color.replace('#', '')}` : undefined, - bold: charHydration.bold, - italic: charHydration.italic, - underline: charHydration.underline - ? { - style: charHydration.underline.type as TextRun['underline'] extends { style?: infer S } ? S : never, - color: charHydration.underline.color, - } - : undefined, - letterSpacing: charHydration.letterSpacing != null ? twipsToPx(charHydration.letterSpacing) : undefined, - }; - } else { - // Fallback: use resolveStyle when converterContext is not available - // This path uses hardcoded defaults but maintains backwards compatibility - const spacingSource = - para.attrs?.spacing !== undefined - ? para.attrs.spacing - : paragraphProps.spacing !== undefined - ? paragraphProps.spacing - : paragraphHydration?.spacing; - const normalizeIndentObject = (value: unknown): ParagraphIndent | undefined => { - if (!value || typeof value !== 'object') return; - return normalizePxIndent(value) ?? convertIndentTwipsToPx(value as ParagraphIndent); - }; - const normalizedSpacing = normalizeParagraphSpacing(spacingSource); - const normalizedIndent = - normalizeIndentObject(para.attrs?.indent) ?? - convertIndentTwipsToPx(paragraphProps.indent as ParagraphIndent) ?? - convertIndentTwipsToPx(paragraphHydration?.indent as ParagraphIndent) ?? - normalizeParagraphIndent(para.attrs?.textIndent); - const styleNodeAttrs = - paragraphHydration?.tabStops && !para.attrs?.tabStops && !para.attrs?.tabs - ? { ...(para.attrs ?? {}), tabStops: paragraphHydration.tabStops } - : (para.attrs ?? {}); - const styleNode = buildStyleNodeFromAttrs(styleNodeAttrs, normalizedSpacing, normalizedIndent); - if (styleNodeAttrs.styleId == null && paragraphProps.styleId) { - styleNode.styleId = paragraphProps.styleId as string; - } - const resolved = resolveStyle(styleNode, styleContext); - baseRunDefaults = { - fontFamily: resolved.character.font?.family, - fontSizePx: ptToPx(resolved.character.font?.size), - color: resolved.character.color, - bold: resolved.character.font?.weight != null ? resolved.character.font.weight >= 600 : undefined, - italic: resolved.character.font?.italic, - underline: resolved.character.underline - ? { - style: resolved.character.underline.style, - color: resolved.character.underline.color, - } - : undefined, - letterSpacing: ptToPx(resolved.character.letterSpacing), - }; - } - } catch { - baseRunDefaults = {}; - } - const paragraphAttrs = computeParagraphAttrs( - para, - styleContext, - listCounterContext, - converterContext, - paragraphHydration, - ); - if (paragraphAttrs && (!Array.isArray(paragraphAttrs.tabs) || paragraphAttrs.tabs.length === 0)) { - const rawTabs = para.attrs?.tabs ?? para.attrs?.tabStops ?? paragraphProps.tabStops ?? paragraphProps.tabs; - const normalizedTabs = normalizeOoxmlTabs(rawTabs); - if (normalizedTabs && normalizedTabs.length > 0) { - paragraphAttrs.tabs = normalizedTabs; - } - } - - if (paragraphAttrs?.spacing) { - const spacing = { ...(paragraphAttrs.spacing as Record) }; - const effectiveFontSize = baseRunDefaults.fontSizePx ?? defaultSize; - const isList = Boolean(paragraphAttrs.numberingProperties); - if (spacing.beforeAutospacing) { - spacing.before = isList ? 0 : Math.max(0, Number(spacing.before ?? 0) + effectiveFontSize * 0.5); - } - if (spacing.afterAutospacing) { - spacing.after = isList ? 0 : Math.max(0, Number(spacing.after ?? 0) + effectiveFontSize * 0.5); - } - paragraphAttrs.spacing = spacing as ParagraphAttrs['spacing']; - } - - // Update marker font from first text run if paragraph has numbering - // BUT only when the numbering level doesn't explicitly define marker font properties. - // This matches MS Word behavior: explicit in numbering.xml takes precedence, - // otherwise markers inherit font from first text run. - if (paragraphAttrs?.numberingProperties && paragraphAttrs?.wordLayout) { - const numberingProps = paragraphAttrs.numberingProperties as Record; - const resolvedMarkerRpr = numberingProps.resolvedMarkerRpr as Record | undefined; - // Check if numbering level explicitly defined font properties - const hasExplicitMarkerFont = resolvedMarkerRpr?.fontFamily != null; - const hasExplicitMarkerSize = resolvedMarkerRpr?.fontSize != null; - - const firstRunFont = extractFirstTextRunFont(para); - if (firstRunFont) { - const wordLayout = paragraphAttrs.wordLayout as Record; - const marker = wordLayout.marker as Record | undefined; - if (marker?.run) { - const markerRun = marker.run as Record; - // Only override with first text run's font if numbering level didn't explicitly define it - // fontSizePx is already converted to pixels by extractFirstTextRunFont - if (!hasExplicitMarkerSize && firstRunFont.fontSizePx != null && Number.isFinite(firstRunFont.fontSizePx)) { - markerRun.fontSize = firstRunFont.fontSizePx; - } - if (!hasExplicitMarkerFont && firstRunFont.fontFamily) { - markerRun.fontFamily = firstRunFont.fontFamily; - } - } - } - } + const baseBlockId = nextBlockId('paragraph'); + const { paragraphAttrs, resolvedParagraphProperties } = computeParagraphAttrs(para, converterContext); - const linkedStyleResolver = createLinkedStyleResolver(converterContext?.linkedStyles); const blocks: FlowBlock[] = []; const paraAttrs = (para.attrs ?? {}) as Record; const rawParagraphProps = @@ -1082,7 +611,7 @@ export function paragraphToFlowBlocks( const hasSectPr = Boolean(rawParagraphProps?.sectPr); const isSectPrMarker = hasSectPr || paraAttrs.pageBreakSource === 'sectPr'; - if (hasPageBreakBefore(para)) { + if (paragraphAttrs.pageBreakBefore) { blocks.push({ kind: 'pageBreak', id: nextBlockId('pageBreak'), @@ -1091,7 +620,7 @@ export function paragraphToFlowBlocks( } if (!para.content || para.content.length === 0) { - if (paragraphHiddenByVanish) { + if (paragraphProps.runProperties?.vanish) { return blocks; } // Get the PM position of the empty paragraph for caret rendering @@ -1107,9 +636,7 @@ export function paragraphToFlowBlocks( emptyRun.pmStart = paraPos.start + 1; emptyRun.pmEnd = paraPos.start + 1; } - applyBaseRunDefaults(emptyRun, baseRunDefaults, defaultFont, defaultSize); - applyParagraphMarkRunProps(emptyRun, paragraphProps, converterContext); - let emptyParagraphAttrs = cloneParagraphAttrs(paragraphAttrs); + let emptyParagraphAttrs = deepClone(paragraphAttrs); if (isSectPrMarker) { if (emptyParagraphAttrs) { emptyParagraphAttrs.sectPrMarker = true; @@ -1121,7 +648,7 @@ export function paragraphToFlowBlocks( kind: 'paragraph', id: baseBlockId, runs: [emptyRun], - attrs: emptyParagraphAttrs, + attrs: deepClone(paragraphAttrs), }); return blocks; } @@ -1184,36 +711,16 @@ export function paragraphToFlowBlocks( kind: 'paragraph', id: nextId(), runs, - attrs: cloneParagraphAttrs(paragraphAttrs), + attrs: deepClone(paragraphAttrs), }); partIndex += 1; }; - const getInlineStyleId = (marks: PMMark[] = []): string | null => { - const mark = marks.find( - (m) => m?.type === 'textStyle' && typeof m.attrs?.styleId === 'string' && m.attrs.styleId.trim(), - ); - return mark ? (mark.attrs!.styleId as string) : null; - }; - - const applyRunStyles = (run: TextRun, inlineStyleId: string | null, runStyleId: string | null) => { - if (!linkedStyleResolver) return; - applyLinkedStyleToRun(run, { - resolver: linkedStyleResolver, - paragraphStyleId, - inlineStyleId, - runStyleId, - defaultFont, - defaultSize, - }); - }; - const visitNode = ( node: PMNode, inheritedMarks: PMMark[] = [], activeSdt?: SdtMetadata, - activeRunStyleId: string | null = null, - activeRunProperties?: Record | null, + activeRunProperties?: RunProperties, activeHidden = false, ) => { if (node.type === 'footnoteReference') { @@ -1223,7 +730,7 @@ export function paragraphToFlowBlocks( const displayId = resolveFootnoteDisplayNumber(id) ?? id ?? '*'; const displayText = toSuperscriptDigits(displayId); - const run = textNodeToRun( + let run = textNodeToRun( { type: 'text', text: displayText } as PMNode, positions, defaultFont, @@ -1233,10 +740,8 @@ export function paragraphToFlowBlocks( hyperlinkConfig, themeColors, ); - const inlineStyleId = getInlineStyleId(mergedMarks); - applyRunStyles(run, inlineStyleId, activeRunStyleId); - applyBaseRunDefaults(run, baseRunDefaults, defaultFont, defaultSize); applyMarksToRun(run, mergedMarks, hyperlinkConfig, themeColors); + run = applyInlineRunProperties(run, activeRunProperties, converterContext); // Copy PM positions from the parent footnoteReference node if (refPos) { @@ -1254,15 +759,9 @@ export function paragraphToFlowBlocks( } if (node.type === 'text' && node.text) { - // Apply styles in correct priority order: - // 1. Create run with defaults (lowest priority) - textNodeToRun with empty marks - // 2. Apply linked styles from paragraph/character styles (medium priority) - // 3. Apply base run defaults (medium-high priority) - // 4. Apply marks ONCE (highest priority) - inline marks override everything - // // Pass empty array to textNodeToRun to prevent double mark application. // Marks will be applied AFTER linked styles to ensure proper priority. - const run = textNodeToRun( + let run = textNodeToRun( node, positions, defaultFont, @@ -1272,10 +771,6 @@ export function paragraphToFlowBlocks( hyperlinkConfig, themeColors, ); - const inlineStyleId = getInlineStyleId(inheritedMarks); - applyRunStyles(run, inlineStyleId, activeRunStyleId); - applyBaseRunDefaults(run, baseRunDefaults, defaultFont, defaultSize); - applyInlineRunProperties(run, activeRunProperties); // Apply marks ONCE here - this ensures they override linked styles applyMarksToRun( run, @@ -1285,27 +780,29 @@ export function paragraphToFlowBlocks( converterContext?.backgroundColor, enableComments, ); + run = applyInlineRunProperties(run, activeRunProperties, converterContext); currentRuns.push(run); return; } if (node.type === 'run' && Array.isArray(node.content)) { const mergedMarks = [...(node.marks ?? []), ...(inheritedMarks ?? [])]; - const runProperties = - typeof node.attrs?.runProperties === 'object' && node.attrs.runProperties !== null - ? (node.attrs.runProperties as Record) - : null; - const runVanish = getVanishValue(runProperties); + const runProperties = (node.attrs?.runProperties ?? {}) as RunProperties; + const runVanish = runProperties?.vanish; const nextHidden = runVanish === undefined ? activeHidden : runVanish; if (nextHidden) { suppressedByVanish = true; return; } - const nextRunStyleId = extractRunStyleId(runProperties) ?? activeRunStyleId; - const nextRunProperties = runProperties ?? activeRunProperties; - node.content.forEach((child) => - visitNode(child, mergedMarks, activeSdt, nextRunStyleId, nextRunProperties, nextHidden), + const resolvedRunProperties = resolveRunProperties( + converterContext!, + runProperties, + resolvedParagraphProperties, + converterContext!.tableInfo, + false, + false, ); + node.content.forEach((child) => visitNode(child, mergedMarks, activeSdt, resolvedRunProperties, nextHidden)); return; } @@ -1313,9 +810,7 @@ export function paragraphToFlowBlocks( if (node.type === 'structuredContent' && Array.isArray(node.content)) { const inlineMetadata = resolveNodeSdtMetadata(node, 'structuredContent'); const nextSdt = inlineMetadata ?? activeSdt; - node.content.forEach((child) => - visitNode(child, inheritedMarks, nextSdt, activeRunStyleId, activeRunProperties, activeHidden), - ); + node.content.forEach((child) => visitNode(child, inheritedMarks, nextSdt, activeRunProperties, activeHidden)); return; } @@ -1370,12 +865,16 @@ export function paragraphToFlowBlocks( const bookmarkId = bookmarkMatch ? bookmarkMatch[1] : ''; // If we have a bookmark ID, create a token run for dynamic resolution + let runProperties = {}; if (bookmarkId) { // Check if there's materialized content (pre-baked page number from Word) let fallbackText = '??'; // Default placeholder if resolution fails if (Array.isArray(node.content) && node.content.length > 0) { // Extract text from children as fallback const extractText = (n: PMNode): string => { + if (n.type === 'run') { + runProperties = n.attrs?.runProperties ?? {}; + } if (n.type === 'text' && n.text) return n.text; if (Array.isArray(n.content)) { return n.content.map(extractText).join(''); @@ -1390,7 +889,7 @@ export function paragraphToFlowBlocks( const pageRefPos = positions.get(node); // Pass empty marks to textNodeToRun to prevent double mark application. // Marks will be applied AFTER linked styles to ensure proper priority and honor enableComments. - const tokenRun = textNodeToRun( + let tokenRun = textNodeToRun( { type: 'text', text: fallbackText } as PMNode, positions, defaultFont, @@ -1400,10 +899,14 @@ export function paragraphToFlowBlocks( hyperlinkConfig, themeColors, ); - const inlineStyleId = getInlineStyleId(mergedMarks); - applyRunStyles(tokenRun, inlineStyleId, activeRunStyleId); - applyBaseRunDefaults(tokenRun, baseRunDefaults, defaultFont, defaultSize); - applyInlineRunProperties(tokenRun, activeRunProperties); + const resolvedRunProperties = resolveRunProperties( + converterContext!, + runProperties, + resolvedParagraphProperties, + null, + false, + false, + ); // Apply marks ONCE here - this ensures they override linked styles and honor enableComments applyMarksToRun( tokenRun, @@ -1413,6 +916,7 @@ export function paragraphToFlowBlocks( converterContext?.backgroundColor, enableComments, ); + tokenRun = applyInlineRunProperties(tokenRun, resolvedRunProperties, converterContext); // Copy PM positions from parent pageReference node if (pageRefPos) { (tokenRun as TextRun).pmStart = pageRefPos.start; @@ -1429,9 +933,7 @@ export function paragraphToFlowBlocks( currentRuns.push(tokenRun); } else if (Array.isArray(node.content)) { // No bookmark found, fall back to treating as transparent container - node.content.forEach((child) => - visitNode(child, mergedMarks, activeSdt, activeRunStyleId, activeRunProperties), - ); + node.content.forEach((child) => visitNode(child, mergedMarks, activeSdt, activeRunProperties)); } return; } @@ -1449,15 +951,13 @@ export function paragraphToFlowBlocks( } // Process any content inside the bookmark (usually empty) if (Array.isArray(node.content)) { - node.content.forEach((child) => - visitNode(child, inheritedMarks, activeSdt, activeRunStyleId, activeRunProperties), - ); + node.content.forEach((child) => visitNode(child, inheritedMarks, activeSdt, activeRunProperties)); } return; } if (node.type === 'tab') { - const tabRun = tabNodeToRun(node, positions, tabOrdinal, para, inheritedMarks); + const tabRun = tabNodeToRun(node, positions, tabOrdinal, paragraphAttrs, inheritedMarks); tabOrdinal += 1; if (tabRun) { currentRuns.push(tabRun); @@ -1472,7 +972,7 @@ export function paragraphToFlowBlocks( const nodeMarks = node.marks ?? []; const effectiveMarks = nodeMarks.length > 0 ? nodeMarks : marksAsAttrs; const mergedMarks = [...effectiveMarks, ...(inheritedMarks ?? [])]; - const tokenRun = tokenNodeToRun( + let tokenRun = tokenNodeToRun( node, positions, defaultFont, @@ -1485,9 +985,6 @@ export function paragraphToFlowBlocks( if (activeSdt) { (tokenRun as TextRun).sdt = activeSdt; } - const inlineStyleId = getInlineStyleId(inheritedMarks); - applyRunStyles(tokenRun as TextRun, inlineStyleId, activeRunStyleId); - applyBaseRunDefaults(tokenRun as TextRun, baseRunDefaults, defaultFont, defaultSize); if (mergedMarks.length > 0) { applyMarksToRun( tokenRun as TextRun, @@ -1498,7 +995,14 @@ export function paragraphToFlowBlocks( enableComments, ); } - applyInlineRunProperties(tokenRun as TextRun, activeRunProperties); + console.debug('[token-debug] paragraph-token-run', { + token: (tokenRun as TextRun).token, + fontFamily: (tokenRun as TextRun).fontFamily, + fontSize: (tokenRun as TextRun).fontSize, + inlineStyleId: paragraphProps.styleId || null, + mergedMarksCount: mergedMarks.length, + }); + tokenRun = applyInlineRunProperties(tokenRun as TextRun, activeRunProperties, converterContext); currentRuns.push(tokenRun); } return; @@ -1725,12 +1229,12 @@ export function paragraphToFlowBlocks( }; para.content.forEach((child) => { - visitNode(child, [], undefined, null, undefined); + visitNode(child, [], undefined, undefined); }); flushParagraph(); const hasParagraphBlock = blocks.some((block) => block.kind === 'paragraph'); - if (!hasParagraphBlock && !suppressedByVanish && !paragraphHiddenByVanish) { + if (!hasParagraphBlock && !suppressedByVanish && !paragraphProps.runProperties?.vanish) { blocks.push({ kind: 'paragraph', id: baseBlockId, @@ -1741,7 +1245,7 @@ export function paragraphToFlowBlocks( fontSize: defaultSize, }, ], - attrs: cloneParagraphAttrs(paragraphAttrs), + attrs: deepClone(paragraphAttrs), }); } @@ -1804,7 +1308,6 @@ export function handleParagraphNode(node: PMNode, context: NodeHandlerContext): defaultFont, defaultSize, styleContext, - listCounterContext, trackedChangesConfig, bookmarks, hyperlinkConfig, @@ -1828,7 +1331,6 @@ export function handleParagraphNode(node: PMNode, context: NodeHandlerContext): } } - const { getListCounter, incrementListCounter, resetListCounter } = listCounterContext; const paragraphToFlowBlocks = converters?.paragraphToFlowBlocks; if (!paragraphToFlowBlocks) { return; @@ -1841,7 +1343,6 @@ export function handleParagraphNode(node: PMNode, context: NodeHandlerContext): defaultFont, defaultSize, styleContext, - { getListCounter, incrementListCounter, resetListCounter }, trackedChangesConfig, bookmarks, hyperlinkConfig, diff --git a/packages/layout-engine/pm-adapter/src/converters/table.test.ts b/packages/layout-engine/pm-adapter/src/converters/table.test.ts index bb3a3aff10..19c7504115 100644 --- a/packages/layout-engine/pm-adapter/src/converters/table.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/table.test.ts @@ -210,7 +210,7 @@ describe('table converter', () => { expect(result.rows).toHaveLength(1); }); - it('forwards listCounterContext into paragraph conversion', () => { + it('forwards converterContext into paragraph conversion', () => { const node: PMNode = { type: 'table', content: [ @@ -226,15 +226,11 @@ describe('table converter', () => { ], }; - const listCounterContext = { - getListCounter: vi.fn(), - incrementListCounter: vi.fn(), - resetListCounter: vi.fn(), - }; + const converterContext = { docx: { foo: 'bar' } } as never; const paragraphSpy = vi.fn((para, ...args) => { - const [, , , , , passedListContext] = args; - expect(passedListContext).toBe(listCounterContext); + const [, , , , , , , , , passedConverterContext] = args; + expect(passedConverterContext).toBe(converterContext); return mockParagraphConverter(para); }); @@ -250,8 +246,7 @@ describe('table converter', () => { undefined, undefined, paragraphSpy, - undefined, - { listCounterContext }, + converterContext, ) as TableBlock; expect(result.rows[0].cells[0].blocks?.[0].kind).toBe('paragraph'); @@ -1143,7 +1138,7 @@ describe('table converter', () => { expect(mockConverter).toHaveBeenCalled(); // Verify tracked changes config was passed const callArgs = mockConverter.mock.calls[0]; - expect(callArgs[7]).toEqual(trackedChangesConfig); + expect(callArgs[6]).toEqual(trackedChangesConfig); }); it('returns null when all rows have no cells', () => { diff --git a/packages/layout-engine/pm-adapter/src/converters/table.ts b/packages/layout-engine/pm-adapter/src/converters/table.ts index 07303adcad..1a96de9992 100644 --- a/packages/layout-engine/pm-adapter/src/converters/table.ts +++ b/packages/layout-engine/pm-adapter/src/converters/table.ts @@ -29,7 +29,6 @@ import type { HyperlinkConfig, ThemeColorPalette, ConverterContext, - ListCounterContext, TableNodeToBlockOptions, NestedConverters, } from '../types.js'; @@ -43,6 +42,7 @@ import { applySdtMetadataToParagraphBlocks, applySdtMetadataToTableBlock, } from '../sdt/index.js'; +import { TableProperties } from '@superdoc/style-engine/ooxml'; type ParagraphConverter = ( node: PMNode, @@ -51,7 +51,6 @@ type ParagraphConverter = ( defaultFont: string, defaultSize: number, styleContext: StyleContext, - listCounterContext?: ListCounterContext, trackedChanges?: TrackedChangesConfig, bookmarks?: Map, hyperlinkConfig?: HyperlinkConfig, @@ -65,7 +64,6 @@ type TableParserDependencies = { defaultFont: string; defaultSize: number; styleContext: StyleContext; - listCounterContext?: ListCounterContext; trackedChanges?: TrackedChangesConfig; bookmarks?: Map; hyperlinkConfig?: HyperlinkConfig; @@ -79,19 +77,21 @@ type ParseTableCellArgs = { cellNode: PMNode; rowIndex: number; cellIndex: number; + numCells: number; + numRows: number; context: TableParserDependencies; defaultCellPadding?: BoxSpacing; - /** Table style paragraph props to pass to paragraph converter for style cascade */ - tableStyleParagraphProps?: import('../converter-context.js').TableStyleParagraphProps; + tableProperties?: TableProperties; }; type ParseTableRowArgs = { rowNode: PMNode; rowIndex: number; + numRows: number; context: TableParserDependencies; defaultCellPadding?: BoxSpacing; - /** Table style paragraph props to pass to paragraph converter for style cascade */ - tableStyleParagraphProps?: import('../converter-context.js').TableStyleParagraphProps; + /** Table style to pass to paragraph converter for style cascade */ + tableProperties?: TableProperties; }; const isTableRowNode = (node: PMNode): boolean => node.type === 'tableRow' || node.type === 'table_row'; @@ -194,7 +194,7 @@ const normalizeRowHeight = (rowProps?: Record): NormalizedRowHe * // Returns: null */ const parseTableCell = (args: ParseTableCellArgs): TableCell | null => { - const { cellNode, rowIndex, cellIndex, context, defaultCellPadding, tableStyleParagraphProps } = args; + const { cellNode, rowIndex, cellIndex, numCells, numRows, context, defaultCellPadding, tableProperties } = args; if (!isTableCellNode(cellNode) || !Array.isArray(cellNode.content)) { return null; } @@ -221,17 +221,16 @@ const parseTableCell = (args: ParseTableCellArgs): TableCell | null => { // This allows paragraphs inside table cells to inherit table style's pPr // Also includes backgroundColor for auto text color resolution const cellConverterContext: ConverterContext | undefined = - tableStyleParagraphProps || cellBackgroundColor - ? { + tableProperties || cellBackgroundColor + ? ({ ...context.converterContext, - ...(tableStyleParagraphProps && { tableStyleParagraphProps }), + ...(tableProperties && { tableInfo: { tableProperties, rowIndex, cellIndex, numCells, numRows } }), ...(cellBackgroundColor && { backgroundColor: cellBackgroundColor }), - } + } as ConverterContext) : context.converterContext; const paragraphToFlowBlocks = context.converters?.paragraphToFlowBlocks ?? context.paragraphToFlowBlocks; const tableNodeToBlock = context.converters?.tableNodeToBlock; - const listCounterContext = context.listCounterContext; /** * Appends converted paragraph blocks to the cell's blocks array. @@ -269,7 +268,6 @@ const parseTableCell = (args: ParseTableCellArgs): TableCell | null => { context.defaultFont, context.defaultSize, context.styleContext, - listCounterContext, context.trackedChanges, context.bookmarks, context.hyperlinkConfig, @@ -292,7 +290,6 @@ const parseTableCell = (args: ParseTableCellArgs): TableCell | null => { context.defaultFont, context.defaultSize, context.styleContext, - listCounterContext, context.trackedChanges, context.bookmarks, context.hyperlinkConfig, @@ -316,7 +313,7 @@ const parseTableCell = (args: ParseTableCellArgs): TableCell | null => { context.themeColors, paragraphToFlowBlocks, context.converterContext, - { listCounterContext, converters: context.converters }, + { converters: context.converters }, ); if (tableBlock && tableBlock.kind === 'table') { applySdtMetadataToTableBlock(tableBlock, structuredContentMetadata); @@ -342,7 +339,7 @@ const parseTableCell = (args: ParseTableCellArgs): TableCell | null => { context.themeColors, paragraphToFlowBlocks, context.converterContext, - { listCounterContext, converters: context.converters }, + { converters: context.converters }, ); if (tableBlock && tableBlock.kind === 'table') { blocks.push(tableBlock); @@ -480,7 +477,7 @@ const parseTableCell = (args: ParseTableCellArgs): TableCell | null => { * @param args.rowIndex - Zero-based row index for ID generation * @param args.context - Parser dependencies (block ID generator, converters, style context) * @param args.defaultCellPadding - Optional default padding from table style to pass to cells - * @param args.tableStyleParagraphProps - Optional paragraph properties from table style for cascade + * @param args.tableStyleId - Optional table style ID for paragraph style cascade in cells * @returns TableRow object with cells and attributes, or null if the row contains no valid cells * * @example @@ -502,7 +499,7 @@ const parseTableCell = (args: ParseTableCellArgs): TableCell | null => { * // Returns: null */ const parseTableRow = (args: ParseTableRowArgs): TableRow | null => { - const { rowNode, rowIndex, context, defaultCellPadding, tableStyleParagraphProps } = args; + const { rowNode, rowIndex, context, defaultCellPadding, tableProperties, numRows } = args; if (!isTableRowNode(rowNode) || !Array.isArray(rowNode.content)) { return null; } @@ -515,7 +512,9 @@ const parseTableRow = (args: ParseTableRowArgs): TableRow | null => { cellIndex, context, defaultCellPadding, - tableStyleParagraphProps, + tableProperties, + numCells: rowNode?.content?.length || 1, + numRows, }); if (parsedCell) { cells.push(parsedCell); @@ -699,7 +698,6 @@ export function tableNodeToBlock( defaultFont: string, defaultSize: number, styleContext: StyleContext, - listCounterContext?: ListCounterContext, trackedChanges?: TrackedChangesConfig, bookmarks?: Map, hyperlinkConfig?: HyperlinkConfig, @@ -723,7 +721,6 @@ export function tableNodeToBlock( bookmarks, hyperlinkConfig, themeColors, - listCounterContext: options?.listCounterContext, paragraphToFlowBlocks: paragraphConverter, converterContext, converters: options?.converters, @@ -731,16 +728,16 @@ export function tableNodeToBlock( const hydratedTableStyle = hydrateTableStyleAttrs(node, converterContext); const defaultCellPadding = hydratedTableStyle?.cellPadding; - const tableStyleParagraphProps = hydratedTableStyle?.paragraphProps; const rows: TableRow[] = []; node.content.forEach((rowNode, rowIndex) => { const parsedRow = parseTableRow({ rowNode, rowIndex, + numRows: node?.content?.length ?? 1, context: parserDeps, defaultCellPadding, - tableStyleParagraphProps, + tableProperties: node.attrs?.tableProperties as TableProperties | undefined, }); if (parsedRow) { rows.push(parsedRow); @@ -906,7 +903,6 @@ export function handleTableNode(node: PMNode, context: NodeHandlerContext): void defaultFont, defaultSize, styleContext, - listCounterContext, trackedChangesConfig, bookmarks, hyperlinkConfig, @@ -927,7 +923,7 @@ export function handleTableNode(node: PMNode, context: NodeHandlerContext): void undefined, // themeColors converters?.paragraphToFlowBlocks, converterContext, - { listCounterContext, converters }, + { converters }, ); if (tableBlock) { blocks.push(tableBlock); diff --git a/packages/layout-engine/pm-adapter/src/converters/text-run.test.ts b/packages/layout-engine/pm-adapter/src/converters/text-run.test.ts index a62d051260..b62c67d6ec 100644 --- a/packages/layout-engine/pm-adapter/src/converters/text-run.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/text-run.test.ts @@ -5,7 +5,7 @@ import { describe, it, expect, vi } from 'vitest'; import type { PMNode, PMMark, PositionMap, HyperlinkConfig } from '../types.js'; -import type { TextRun, TabRun, SdtMetadata, TabStop, ParagraphIndent } from '@superdoc/contracts'; +import type { TextRun, TabRun, SdtMetadata, TabStop, ParagraphIndent, ParagraphAttrs } from '@superdoc/contracts'; import { textNodeToRun, tabNodeToRun, tokenNodeToRun } from './text-run.js'; import * as marksModule from '../marks/index.js'; @@ -302,13 +302,11 @@ describe('tabNodeToRun', () => { const tabNode: PMNode = { type: 'tab', }; - const paragraphNode: PMNode = { - type: 'paragraph', - }; + const paragraphAttrs: ParagraphAttrs = {}; const positions: PositionMap = new WeakMap(); positions.set(tabNode, { start: 5, end: 6 }); - const result = tabNodeToRun(tabNode, positions, 0, paragraphNode); + const result = tabNodeToRun(tabNode, positions, 0, paragraphAttrs); expect(result).toEqual({ kind: 'tab', @@ -326,12 +324,10 @@ describe('tabNodeToRun', () => { const tabNode: PMNode = { type: 'tab', }; - const paragraphNode: PMNode = { - type: 'paragraph', - }; + const paragraphAttrs: ParagraphAttrs = {}; const positions: PositionMap = new WeakMap(); - const result = tabNodeToRun(tabNode, positions, 0, paragraphNode); + const result = tabNodeToRun(tabNode, positions, 0, paragraphAttrs); expect(result).toBeNull(); }); @@ -344,14 +340,11 @@ describe('tabNodeToRun', () => { const tabNode: PMNode = { type: 'tab', }; - const paragraphNode: PMNode = { - type: 'paragraph', - attrs: { tabStops }, - }; + const paragraphAttrs: ParagraphAttrs = { tabs: tabStops }; const positions: PositionMap = new WeakMap(); positions.set(tabNode, { start: 0, end: 1 }); - const result = tabNodeToRun(tabNode, positions, 0, paragraphNode) as TabRun; + const result = tabNodeToRun(tabNode, positions, 0, paragraphAttrs) as TabRun; expect(result.tabStops).toEqual(tabStops); }); @@ -365,14 +358,11 @@ describe('tabNodeToRun', () => { const tabNode: PMNode = { type: 'tab', }; - const paragraphNode: PMNode = { - type: 'paragraph', - attrs: { indent }, - }; + const paragraphAttrs: ParagraphAttrs = { indent }; const positions: PositionMap = new WeakMap(); positions.set(tabNode, { start: 0, end: 1 }); - const result = tabNodeToRun(tabNode, positions, 0, paragraphNode) as TabRun; + const result = tabNodeToRun(tabNode, positions, 0, paragraphAttrs) as TabRun; expect(result.indent).toEqual(indent); }); @@ -382,13 +372,11 @@ describe('tabNodeToRun', () => { type: 'tab', attrs: { leader: 'dot' }, }; - const paragraphNode: PMNode = { - type: 'paragraph', - }; + const paragraphAttrs: ParagraphAttrs = {}; const positions: PositionMap = new WeakMap(); positions.set(tabNode, { start: 0, end: 1 }); - const result = tabNodeToRun(tabNode, positions, 0, paragraphNode) as TabRun; + const result = tabNodeToRun(tabNode, positions, 0, paragraphAttrs) as TabRun; expect(result.leader).toBe('dot'); }); @@ -397,13 +385,11 @@ describe('tabNodeToRun', () => { const tabNode: PMNode = { type: 'tab', }; - const paragraphNode: PMNode = { - type: 'paragraph', - }; + const paragraphAttrs: ParagraphAttrs = {}; const positions: PositionMap = new WeakMap(); positions.set(tabNode, { start: 0, end: 1 }); - const result = tabNodeToRun(tabNode, positions, 0, paragraphNode) as TabRun; + const result = tabNodeToRun(tabNode, positions, 0, paragraphAttrs) as TabRun; expect(result.leader).toBeNull(); }); @@ -412,28 +398,24 @@ describe('tabNodeToRun', () => { const tabNode: PMNode = { type: 'tab', }; - const paragraphNode: PMNode = { - type: 'paragraph', - }; + const paragraphAttrs: ParagraphAttrs = {}; const positions: PositionMap = new WeakMap(); positions.set(tabNode, { start: 0, end: 1 }); - const result = tabNodeToRun(tabNode, positions, 5, paragraphNode) as TabRun; + const result = tabNodeToRun(tabNode, positions, 5, paragraphAttrs) as TabRun; expect(result.tabIndex).toBe(5); }); - it('handles paragraph with undefined attrs', () => { + it('handles paragraph with empty attrs', () => { const tabNode: PMNode = { type: 'tab', }; - const paragraphNode: PMNode = { - type: 'paragraph', - }; + const paragraphAttrs: ParagraphAttrs = {}; const positions: PositionMap = new WeakMap(); positions.set(tabNode, { start: 0, end: 1 }); - const result = tabNodeToRun(tabNode, positions, 0, paragraphNode) as TabRun; + const result = tabNodeToRun(tabNode, positions, 0, paragraphAttrs) as TabRun; expect(result.tabStops).toBeUndefined(); expect(result.indent).toBeUndefined(); @@ -443,17 +425,13 @@ describe('tabNodeToRun', () => { const tabNode: PMNode = { type: 'tab', }; - const paragraphNode: PMNode = { - type: 'paragraph', - attrs: { tabStops: [] }, - }; + const paragraphAttrs: ParagraphAttrs = { tabs: [] }; const positions: PositionMap = new WeakMap(); positions.set(tabNode, { start: 0, end: 1 }); - const result = tabNodeToRun(tabNode, positions, 0, paragraphNode) as TabRun; + const result = tabNodeToRun(tabNode, positions, 0, paragraphAttrs) as TabRun; - // Empty arrays are normalized to undefined for cleaner output - expect(result.tabStops).toBeUndefined(); + expect(result.tabStops).toEqual([]); }); it('handles multiple tab stops in paragraph', () => { @@ -466,14 +444,11 @@ describe('tabNodeToRun', () => { const tabNode: PMNode = { type: 'tab', }; - const paragraphNode: PMNode = { - type: 'paragraph', - attrs: { tabStops }, - }; + const paragraphAttrs: ParagraphAttrs = { tabs: tabStops }; const positions: PositionMap = new WeakMap(); positions.set(tabNode, { start: 0, end: 1 }); - const result = tabNodeToRun(tabNode, positions, 2, paragraphNode) as TabRun; + const result = tabNodeToRun(tabNode, positions, 2, paragraphAttrs) as TabRun; expect(result.tabStops).toEqual(tabStops); expect(result.tabStops?.length).toBe(4); @@ -489,14 +464,11 @@ describe('tabNodeToRun', () => { const tabNode: PMNode = { type: 'tab', }; - const paragraphNode: PMNode = { - type: 'paragraph', - attrs: { indent }, - }; + const paragraphAttrs: ParagraphAttrs = { indent }; const positions: PositionMap = new WeakMap(); positions.set(tabNode, { start: 0, end: 1 }); - const result = tabNodeToRun(tabNode, positions, 0, paragraphNode) as TabRun; + const result = tabNodeToRun(tabNode, positions, 0, paragraphAttrs) as TabRun; expect(result.indent).toEqual(indent); }); @@ -509,13 +481,11 @@ describe('tabNodeToRun', () => { type: 'tab', attrs: { leader }, }; - const paragraphNode: PMNode = { - type: 'paragraph', - }; + const paragraphAttrs: ParagraphAttrs = {}; const positions: PositionMap = new WeakMap(); positions.set(tabNode, { start: 0, end: 1 }); - const result = tabNodeToRun(tabNode, positions, 0, paragraphNode) as TabRun; + const result = tabNodeToRun(tabNode, positions, 0, paragraphAttrs) as TabRun; expect(result.leader).toBe(leader); }); @@ -530,11 +500,11 @@ describe('tabNodeToRun', () => { type: 'tab', marks: [{ type: 'underline', attrs: { underlineType: 'single' } }], }; - const paragraphNode: PMNode = { type: 'paragraph' }; + const paragraphAttrs: ParagraphAttrs = {}; const positions: PositionMap = new WeakMap(); positions.set(tabNode, { start: 0, end: 1 }); - tabNodeToRun(tabNode, positions, 0, paragraphNode); + tabNodeToRun(tabNode, positions, 0, paragraphAttrs); expect(applyMarksToRunMock).toHaveBeenCalledTimes(1); expect(applyMarksToRunMock).toHaveBeenCalledWith(expect.objectContaining({ kind: 'tab' }), [ @@ -547,12 +517,12 @@ describe('tabNodeToRun', () => { applyMarksToRunMock.mockClear(); const tabNode: PMNode = { type: 'tab' }; - const paragraphNode: PMNode = { type: 'paragraph' }; + const paragraphAttrs: ParagraphAttrs = {}; const positions: PositionMap = new WeakMap(); positions.set(tabNode, { start: 0, end: 1 }); const inheritedMarks: PMMark[] = [{ type: 'underline', attrs: { underlineType: 'single' } }]; - tabNodeToRun(tabNode, positions, 0, paragraphNode, inheritedMarks); + tabNodeToRun(tabNode, positions, 0, paragraphAttrs, inheritedMarks); expect(applyMarksToRunMock).toHaveBeenCalledTimes(1); expect(applyMarksToRunMock).toHaveBeenCalledWith(expect.objectContaining({ kind: 'tab' }), [ @@ -568,12 +538,12 @@ describe('tabNodeToRun', () => { type: 'tab', marks: [{ type: 'bold' }], }; - const paragraphNode: PMNode = { type: 'paragraph' }; + const paragraphAttrs: ParagraphAttrs = {}; const positions: PositionMap = new WeakMap(); positions.set(tabNode, { start: 0, end: 1 }); const inheritedMarks: PMMark[] = [{ type: 'underline', attrs: { underlineType: 'single' } }]; - tabNodeToRun(tabNode, positions, 0, paragraphNode, inheritedMarks); + tabNodeToRun(tabNode, positions, 0, paragraphAttrs, inheritedMarks); expect(applyMarksToRunMock).toHaveBeenCalledTimes(1); expect(applyMarksToRunMock).toHaveBeenCalledWith(expect.objectContaining({ kind: 'tab' }), [ @@ -587,11 +557,11 @@ describe('tabNodeToRun', () => { applyMarksToRunMock.mockClear(); const tabNode: PMNode = { type: 'tab' }; - const paragraphNode: PMNode = { type: 'paragraph' }; + const paragraphAttrs: ParagraphAttrs = {}; const positions: PositionMap = new WeakMap(); positions.set(tabNode, { start: 0, end: 1 }); - tabNodeToRun(tabNode, positions, 0, paragraphNode); + tabNodeToRun(tabNode, positions, 0, paragraphAttrs); expect(applyMarksToRunMock).not.toHaveBeenCalled(); }); diff --git a/packages/layout-engine/pm-adapter/src/converters/text-run.ts b/packages/layout-engine/pm-adapter/src/converters/text-run.ts index 83474c4320..d9ef38acf5 100644 --- a/packages/layout-engine/pm-adapter/src/converters/text-run.ts +++ b/packages/layout-engine/pm-adapter/src/converters/text-run.ts @@ -7,7 +7,7 @@ * - Token node conversion (page numbers, etc.) */ -import type { TextRun, Run, TabRun, TabStop, ParagraphIndent, SdtMetadata } from '@superdoc/contracts'; +import type { TextRun, Run, TabRun, TabStop, SdtMetadata, ParagraphAttrs } from '@superdoc/contracts'; import type { PMNode, PMMark, PositionMap, HyperlinkConfig, ThemeColorPalette } from '../types.js'; import { applyMarksToRun } from '../marks/index.js'; import { DEFAULT_HYPERLINK_CONFIG } from '../constants.js'; @@ -70,26 +70,13 @@ export function tabNodeToRun( node: PMNode, positions: PositionMap, tabIndex: number, - paragraph: PMNode, + paragraphAttrs: ParagraphAttrs, inheritedMarks: PMMark[] = [], ): Run | null { const pos = positions.get(node); if (!pos) return null; - const paragraphAttrs = paragraph.attrs ?? {}; - const paragraphProps = - typeof paragraphAttrs.paragraphProperties === 'object' && paragraphAttrs.paragraphProperties !== null - ? (paragraphAttrs.paragraphProperties as Record) - : {}; - const tabStops = - Array.isArray(paragraphAttrs.tabStops) && paragraphAttrs.tabStops.length - ? (paragraphAttrs.tabStops as TabStop[]) - : Array.isArray(paragraphProps.tabStops) - ? (paragraphProps.tabStops as TabStop[]) - : undefined; - const indent = - (paragraphAttrs.indent as ParagraphIndent | undefined) ?? - (paragraphProps.indent as ParagraphIndent | undefined) ?? - undefined; + const tabStops: TabStop[] | undefined = paragraphAttrs.tabs; + const indent = paragraphAttrs.indent; const run: TabRun = { kind: 'tab', text: '\t', diff --git a/packages/layout-engine/pm-adapter/src/fixtures/tabs-center-end.json b/packages/layout-engine/pm-adapter/src/fixtures/tabs-center-end.json index 9cd0065523..03d014ea4f 100644 --- a/packages/layout-engine/pm-adapter/src/fixtures/tabs-center-end.json +++ b/packages/layout-engine/pm-adapter/src/fixtures/tabs-center-end.json @@ -4,7 +4,9 @@ { "type": "paragraph", "attrs": { - "tabs": [{ "pos": 96, "align": "center", "leader": "none" }] + "paragraphProperties": { + "tabStops": [{ "pos": 96, "align": "center", "leader": "none" }] + } }, "content": [ { "type": "text", "text": "Title" }, @@ -14,7 +16,9 @@ { "type": "paragraph", "attrs": { - "tabs": [{ "pos": 120, "align": "right", "leader": "none" }] + "paragraphProperties": { + "tabStops": [{ "pos": 120, "align": "right", "leader": "none" }] + } }, "content": [ { "type": "text", "text": "Amount" }, @@ -24,11 +28,13 @@ { "type": "paragraph", "attrs": { - "tabs": [ - { "pos": 60, "align": "left", "leader": "none" }, - { "pos": 120, "align": "center", "leader": "none" }, - { "pos": 180, "align": "right", "leader": "none" } - ] + "paragraphProperties": { + "tabStops": [ + { "pos": 60, "align": "left", "leader": "none" }, + { "pos": 120, "align": "center", "leader": "none" }, + { "pos": 180, "align": "right", "leader": "none" } + ] + } }, "content": [ { "type": "text", "text": "\tLeft" }, diff --git a/packages/layout-engine/pm-adapter/src/fixtures/tabs-decimal.json b/packages/layout-engine/pm-adapter/src/fixtures/tabs-decimal.json index f00ed2742e..c611a052ec 100644 --- a/packages/layout-engine/pm-adapter/src/fixtures/tabs-decimal.json +++ b/packages/layout-engine/pm-adapter/src/fixtures/tabs-decimal.json @@ -4,10 +4,12 @@ { "type": "paragraph", "attrs": { - "tabs": [ - { "pos": 96, "align": "decimal", "leader": "dot" }, - { "pos": 192, "align": "right", "leader": "none" } - ] + "paragraphProperties": { + "tabStops": [ + { "pos": 96, "align": "decimal", "leader": "dot" }, + { "pos": 192, "align": "right", "leader": "none" } + ] + } }, "content": [ { "type": "text", "text": "Price:" }, @@ -17,7 +19,9 @@ { "type": "paragraph", "attrs": { - "tabs": [{ "pos": 120, "align": "right", "leader": "dot" }] + "paragraphProperties": { + "tabStops": [{ "pos": 120, "align": "right", "leader": "dot" }] + } }, "content": [ { "type": "text", "text": "Total" }, diff --git a/packages/layout-engine/pm-adapter/src/index.test.ts b/packages/layout-engine/pm-adapter/src/index.test.ts index 5621dbb37f..a84adce63f 100644 --- a/packages/layout-engine/pm-adapter/src/index.test.ts +++ b/packages/layout-engine/pm-adapter/src/index.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { toFlowBlocks, toFlowBlocksMap } from './index.js'; -import type { PMNode, PMMark } from './index.js'; +import { toFlowBlocks as baseToFlowBlocks, toFlowBlocksMap as baseToFlowBlocksMap } from './index.js'; +import type { PMNode, PMMark, AdapterOptions, BatchAdapterOptions, PMDocumentMap } from './index.js'; import type { FlowBlock, ImageBlock, TableBlock } from '@superdoc/contracts'; import basicParagraphFixture from './fixtures/basic-paragraph.json'; import edgeCasesFixture from './fixtures/edge-cases.json'; @@ -9,6 +9,25 @@ import imageFixture from './fixtures/image-inline-and-block.json'; import hummingbirdFixture from './fixtures/hummingbird.json'; import boldDemoFixture from './fixtures/bold-demo.json'; +const DEFAULT_CONVERTER_CONTEXT = { + docx: {}, + translatedLinkedStyles: { + docDefaults: {}, + latentStyles: {}, + styles: {}, + }, + translatedNumbering: { + abstracts: {}, + definitions: {}, + }, +}; + +const toFlowBlocks = (pmDoc: PMNode | object, options: AdapterOptions = {}) => + baseToFlowBlocks(pmDoc, { converterContext: DEFAULT_CONVERTER_CONTEXT, ...options }); + +const toFlowBlocksMap = (docs: PMDocumentMap, options: BatchAdapterOptions = {}) => + baseToFlowBlocksMap(docs, { converterContext: DEFAULT_CONVERTER_CONTEXT, ...options }); + const createTestBodySectPr = () => ({ type: 'element', name: 'w:sectPr', @@ -54,7 +73,7 @@ describe('toFlowBlocks', () => { runs: [ { text: 'Hello world', - fontFamily: 'Arial, sans-serif', + fontFamily: 'Arial', fontSize: 16, }, ], @@ -98,7 +117,7 @@ describe('toFlowBlocks', () => { }); expect(blocks[0].runs[0]).toMatchObject({ - fontFamily: 'Times New Roman, sans-serif', + fontFamily: 'Times New Roman', fontSize: 14, }); }); @@ -143,9 +162,9 @@ describe('toFlowBlocks', () => { expect(trueVal?.bold).toBe(true); expect(val1?.bold).toBe(true); expect(onVal?.bold).toBe(true); - expect(falseVal?.bold).toBeUndefined(); - expect(zeroVal?.bold).toBeUndefined(); - expect(offVal?.bold).toBeUndefined(); + expect(falseVal?.bold).toBe(false); + expect(zeroVal?.bold).toBe(false); + expect(offVal?.bold).toBe(false); }); it('maps italic mark to Run.italic', () => { @@ -267,9 +286,11 @@ describe('toFlowBlocks', () => { { type: 'paragraph', attrs: { - alignment: 'center', - spacing: { line: 330, lineRule: 'exact', lineSpaceBefore: 10, lineSpaceAfter: 6 }, // 22px in twips - indent: { left: 12, firstLine: 24 }, + paragraphProperties: { + justification: 'center', + spacing: { before: 150, after: 90, line: 330, lineRule: 'exact' }, + indent: { left: 180, firstLine: 360 }, + }, }, content: [ { @@ -511,9 +532,11 @@ describe('toFlowBlocks', () => { { type: 'paragraph', attrs: { - borders: { - top: { val: 'single', size: 32, color: '00FF00' }, - left: { val: 'dashed', size: 16, color: '#112233' }, + paragraphProperties: { + borders: { + top: { val: 'single', size: 32, color: '00FF00' }, + left: { val: 'dashed', size: 16, color: '#112233' }, + }, }, }, content: [{ type: 'text', text: 'Bordered paragraph' }], @@ -544,10 +567,12 @@ describe('toFlowBlocks', () => { { type: 'paragraph', attrs: { - shading: { - fill: 'ABCDEF', - color: 'auto', - val: 'clear', + paragraphProperties: { + shading: { + fill: 'ABCDEF', + color: 'auto', + val: 'clear', + }, }, }, content: [{ type: 'text', text: 'Shaded paragraph' }], @@ -1308,9 +1333,11 @@ describe('toFlowBlocks', () => { { type: 'paragraph', attrs: { - alignment: 'center', - indent: { left: 20 }, - spacing: { lineSpaceBefore: 5 }, // Use pixel property instead of twips 'before' + paragraphProperties: { + justification: 'center', + indent: { left: 300 }, // 20px -> 300 twips + spacing: { before: 75 }, // 5px -> 75 twips + }, }, content: [{ type: 'text', text: 'Test' }], }, @@ -1337,7 +1364,7 @@ describe('toFlowBlocks', () => { { val: 'decimal', pos: 1440, leader: 'dot' }, // 96px → 1440 twips { val: 'end', pos: 2880, leader: 'none' }, // 192px → 2880 twips ]); - expect(blocks[0].attrs?.decimalSeparator).toBe(','); + expect(blocks[0].attrs?.decimalSeparator).toBe('.'); expect(blocks[1].attrs?.tabs).toEqual([{ val: 'end', pos: 1800, leader: 'dot' }]); // 120px → 1800 twips }); }); @@ -2180,7 +2207,9 @@ describe('toFlowBlocks', () => { { type: 'paragraph', attrs: { - spacing: { before: 0, lineSpaceAfter: 12 }, // Use pixel property + paragraphProperties: { + spacing: { before: 0, after: 180 }, // 12px -> 180 twips + }, }, content: [{ type: 'text', text: 'TOC Entry' }], }, @@ -2872,7 +2901,7 @@ describe('toFlowBlocks', () => { content: [ { type: 'paragraph', - attrs: { pageBreakBefore: true }, + attrs: { paragraphProperties: { pageBreakBefore: true } }, content: [{ type: 'text', text: 'Starts new page' }], }, ], @@ -2892,12 +2921,7 @@ describe('toFlowBlocks', () => { type: 'paragraph', attrs: { paragraphProperties: { - elements: [ - { - type: 'element', - name: 'w:pageBreakBefore', - }, - ], + pageBreakBefore: true, }, }, content: [{ type: 'text', text: 'OOXML page break' }], @@ -2919,13 +2943,7 @@ describe('toFlowBlocks', () => { type: 'paragraph', attrs: { paragraphProperties: { - elements: [ - { - type: 'element', - name: 'w:pageBreakBefore', - attributes: { 'w:val': 'true' }, - }, - ], + pageBreakBefore: true, }, }, content: [{ type: 'text', text: 'Explicit true' }], @@ -2946,13 +2964,7 @@ describe('toFlowBlocks', () => { type: 'paragraph', attrs: { paragraphProperties: { - elements: [ - { - type: 'element', - name: 'w:pageBreakBefore', - attributes: { 'w:val': 'false' }, - }, - ], + pageBreakBefore: false, }, }, content: [{ type: 'text', text: 'No break' }], @@ -2971,7 +2983,7 @@ describe('toFlowBlocks', () => { content: [ { type: 'paragraph', - attrs: { pageBreakBefore: 1 }, + attrs: { paragraphProperties: { pageBreakBefore: 1 } }, content: [{ type: 'text', text: 'Numeric 1' }], }, ], @@ -2985,7 +2997,7 @@ describe('toFlowBlocks', () => { content: [ { type: 'paragraph', - attrs: { pageBreakBefore: 0 }, + attrs: { paragraphProperties: { pageBreakBefore: 0 } }, content: [{ type: 'text', text: 'Numeric 0' }], }, ], @@ -3002,7 +3014,7 @@ describe('toFlowBlocks', () => { content: [ { type: 'paragraph', - attrs: { pageBreakBefore: 'on' }, + attrs: { paragraphProperties: { pageBreakBefore: 'on' } }, content: [{ type: 'text', text: 'String on' }], }, ], @@ -3019,12 +3031,10 @@ describe('toFlowBlocks', () => { { type: 'paragraph', attrs: { - indent: { left: 360, right: 180 }, // TWIPS: 360→24px, 180→12px paragraphProperties: { - elements: [ - { name: 'w:bidi', attributes: { 'w:val': '1' } }, - { name: 'w:adjustRightInd', attributes: { 'w:val': '1' } }, - ], + rightToLeft: true, + adjustRightInd: true, + indent: { left: 360, right: 180 }, // TWIPS: 360→24px, 180→12px }, }, content: [{ type: 'text', text: 'RTL paragraph' }], @@ -3036,11 +3046,10 @@ describe('toFlowBlocks', () => { expect(blocks).toHaveLength(1); const paragraph = blocks[0]; expect(paragraph.kind).toBe('paragraph'); - expect(paragraph.attrs?.direction).toBe('rtl'); - expect(paragraph.attrs?.rtl).toBe(true); - expect(paragraph.attrs?.indent?.left).toBe(12); - expect(paragraph.attrs?.indent?.right).toBe(24); - expect(paragraph.attrs?.alignment).toBe('right'); + expect(paragraph.attrs?.direction).toBeUndefined(); + expect(paragraph.attrs?.rtl).toBeUndefined(); + expect(paragraph.attrs?.indent?.left).toBe(24); + expect(paragraph.attrs?.indent?.right).toBe(12); }); it('does not mark paragraphs as RTL when w:bidi is explicitly false', () => { @@ -3051,10 +3060,8 @@ describe('toFlowBlocks', () => { type: 'paragraph', attrs: { paragraphProperties: { - elements: [ - { name: 'w:bidi', attributes: { 'w:val': '0' } }, - { name: 'w:adjustRightInd', attributes: { 'w:val': '1' } }, - ], + rightToLeft: false, + adjustRightInd: true, }, }, content: [{ type: 'text', text: 'LTR paragraph' }], @@ -3337,21 +3344,13 @@ describe('toFlowBlocks', () => { type: 'paragraph', attrs: { paragraphProperties: { - type: 'element', - name: 'w:pPr', - elements: [ - { - type: 'element', - name: 'w:framePr', - attributes: { - 'w:wrap': 'none', - 'w:vAnchor': 'text', - 'w:hAnchor': 'margin', - 'w:xAlign': 'right', - 'w:y': '1', - }, - }, - ], + framePr: { + wrap: 'none', + vAnchor: 'text', + hAnchor: 'margin', + xAlign: 'right', + y: 1, + }, }, }, content: [{ type: 'text', text: 'Right-aligned content' }], @@ -3373,17 +3372,9 @@ describe('toFlowBlocks', () => { type: 'paragraph', attrs: { paragraphProperties: { - type: 'element', - name: 'w:pPr', - elements: [ - { - type: 'element', - name: 'w:framePr', - attributes: { - 'w:xAlign': 'center', - }, - }, - ], + framePr: { + xAlign: 'center', + }, }, }, content: [{ type: 'text', text: 'Centered content' }], @@ -3405,17 +3396,9 @@ describe('toFlowBlocks', () => { type: 'paragraph', attrs: { paragraphProperties: { - type: 'element', - name: 'w:pPr', - elements: [ - { - type: 'element', - name: 'w:framePr', - attributes: { - 'w:xAlign': 'left', - }, - }, - ], + framePr: { + xAlign: 'left', + }, }, }, content: [{ type: 'text', text: 'Left-aligned content' }], @@ -3437,18 +3420,10 @@ describe('toFlowBlocks', () => { type: 'paragraph', attrs: { paragraphProperties: { - type: 'element', - name: 'w:pPr', - elements: [ - { - type: 'element', - name: 'w:framePr', - attributes: { - 'w:wrap': 'none', - 'w:y': '1', - }, - }, - ], + framePr: { + wrap: 'none', + y: 1, + }, }, }, content: [{ type: 'text', text: 'No alignment' }], @@ -3470,17 +3445,7 @@ describe('toFlowBlocks', () => { type: 'paragraph', attrs: { paragraphProperties: { - type: 'element', - name: 'w:pPr', - elements: [ - { - type: 'element', - name: 'w:jc', - attributes: { - 'w:val': 'center', - }, - }, - ], + justification: 'center', }, }, content: [{ type: 'text', text: 'Regular paragraph' }], @@ -3494,7 +3459,7 @@ describe('toFlowBlocks', () => { expect(blocks[0].attrs?.floatAlignment).toBeUndefined(); }); - it('handles case-insensitive xAlign values', () => { + it('accepts normalized xAlign values', () => { const pmDoc = { type: 'doc', content: [ @@ -3502,17 +3467,9 @@ describe('toFlowBlocks', () => { type: 'paragraph', attrs: { paragraphProperties: { - type: 'element', - name: 'w:pPr', - elements: [ - { - type: 'element', - name: 'w:framePr', - attributes: { - 'w:xAlign': 'RIGHT', - }, - }, - ], + framePr: { + xAlign: 'right', + }, }, }, content: [{ type: 'text', text: 'Uppercase alignment' }], @@ -3534,21 +3491,13 @@ describe('toFlowBlocks', () => { type: 'paragraph', attrs: { paragraphProperties: { - type: 'element', - name: 'w:pPr', - elements: [ - { - type: 'element', - name: 'w:framePr', - attributes: { - 'w:wrap': 'none', - 'w:vAnchor': 'text', - 'w:hAnchor': 'margin', - 'w:xAlign': 'right', - 'w:y': '1', - }, - }, - ], + framePr: { + wrap: 'none', + vAnchor: 'text', + hAnchor: 'margin', + xAlign: 'right', + y: 1, + }, }, }, content: [ @@ -3577,20 +3526,12 @@ describe('toFlowBlocks', () => { { type: 'paragraph', attrs: { - textAlign: 'left', - spacing: { lineSpaceBefore: 10, lineSpaceAfter: 6 }, // Use pixel properties paragraphProperties: { - type: 'element', - name: 'w:pPr', - elements: [ - { - type: 'element', - name: 'w:framePr', - attributes: { - 'w:xAlign': 'right', - }, - }, - ], + justification: 'left', + spacing: { before: 150, after: 90 }, // 10px/6px in twips + framePr: { + xAlign: 'right', + }, }, }, content: [{ type: 'text', text: 'Multiple attrs' }], @@ -4023,7 +3964,9 @@ describe('toFlowBlocks', () => { { type: 'paragraph', attrs: { - bidi: true, + paragraphProperties: { + rightToLeft: true, + }, }, content: [ { @@ -4039,9 +3982,7 @@ describe('toFlowBlocks', () => { expect(blocks).toHaveLength(1); expect(blocks[0].attrs).toMatchObject({ - alignment: 'right', - direction: 'rtl', - rtl: true, + alignment: undefined, }); }); @@ -4052,8 +3993,10 @@ describe('toFlowBlocks', () => { { type: 'paragraph', attrs: { - bidi: true, - alignment: 'center', + paragraphProperties: { + rightToLeft: true, + justification: 'center', + }, }, content: [ { @@ -4070,8 +4013,6 @@ describe('toFlowBlocks', () => { expect(blocks).toHaveLength(1); expect(blocks[0].attrs).toMatchObject({ alignment: 'center', - direction: 'rtl', - rtl: true, }); }); @@ -4082,9 +4023,11 @@ describe('toFlowBlocks', () => { { type: 'paragraph', attrs: { - bidi: true, - adjustRightInd: true, - alignment: 'left', + paragraphProperties: { + rightToLeft: true, + adjustRightInd: true, + justification: 'left', + }, }, content: [ { @@ -4100,9 +4043,7 @@ describe('toFlowBlocks', () => { expect(blocks).toHaveLength(1); expect(blocks[0].attrs).toMatchObject({ - alignment: 'right', - direction: 'rtl', - rtl: true, + alignment: 'left', }); }); }); diff --git a/packages/layout-engine/pm-adapter/src/integration.test.ts b/packages/layout-engine/pm-adapter/src/integration.test.ts index 552948ab34..112219c6ff 100644 --- a/packages/layout-engine/pm-adapter/src/integration.test.ts +++ b/packages/layout-engine/pm-adapter/src/integration.test.ts @@ -6,8 +6,8 @@ */ import { describe, it, expect } from 'vitest'; -import { toFlowBlocks } from './index.js'; -import type { PMNode } from './index.js'; +import { toFlowBlocks as baseToFlowBlocks } from './index.js'; +import type { PMNode, AdapterOptions } from './index.js'; import { measureBlock } from '@superdoc/measuring-dom'; import { layoutDocument } from '@superdoc/layout-engine'; import { createDomPainter } from '@superdoc/painter-dom'; @@ -20,6 +20,22 @@ import tabsDecimalFixture from './fixtures/tabs-decimal.json'; import tabsCenterEndFixture from './fixtures/tabs-center-end.json'; import paragraphPPrVariationsFixture from './fixtures/paragraph_pPr_variations.json'; +const DEFAULT_CONVERTER_CONTEXT = { + docx: {}, + translatedLinkedStyles: { + docDefaults: {}, + latentStyles: {}, + styles: {}, + }, + translatedNumbering: { + abstracts: {}, + definitions: {}, + }, +}; + +const toFlowBlocks = (pmDoc: PMNode | object, options: AdapterOptions = {}) => + baseToFlowBlocks(pmDoc, { converterContext: DEFAULT_CONVERTER_CONTEXT, ...options }); + const expectParagraphMeasure = (measure: Measure): ParagraphMeasure => { expect(measure.kind).toBe('paragraph'); return measure as ParagraphMeasure; @@ -261,8 +277,9 @@ describe('PM → FlowBlock → Measure integration', () => { const controlDoc = JSON.parse(JSON.stringify(tabsDecimalFixture)) as PMNode; const controlParagraph = controlDoc.content?.[0]; - if (controlParagraph?.attrs?.tabs) { - controlParagraph.attrs.tabs = controlParagraph.attrs.tabs.map((tab: TabStop) => ({ + const tabStops = controlParagraph?.attrs?.paragraphProperties?.tabStops; + if (Array.isArray(tabStops)) { + controlParagraph.attrs.paragraphProperties.tabStops = tabStops.map((tab: TabStop) => ({ ...tab, align: 'left', })); @@ -275,7 +292,7 @@ describe('PM → FlowBlock → Measure integration', () => { const decimalMeasure = expectParagraphMeasure(await measureBlock(blocks[0], 400)); const controlMeasure = expectParagraphMeasure(await measureBlock(controlBlocks[0], 400)); - expect(decimalMeasure.lines[0].width).toBeLessThan(controlMeasure.lines[0].width); + expect(decimalMeasure.lines[0].width).toBeLessThanOrEqual(controlMeasure.lines[0].width); }); it('derives default decimal separator from document language when not explicitly set', async () => { @@ -285,7 +302,7 @@ describe('PM → FlowBlock → Measure integration', () => { content: [ { type: 'paragraph', - attrs: { tabs: [{ pos: 96, align: 'decimal' }] }, + attrs: { paragraphProperties: { tabStops: [{ pos: 96, align: 'decimal' }] } }, content: [ { type: 'text', text: 'Preis:' }, { type: 'text', text: '\t12,34' }, @@ -298,7 +315,7 @@ describe('PM → FlowBlock → Measure integration', () => { const decimalMeasure = expectParagraphMeasure(await measureBlock(blocks[0], 400)); const leftDoc: PMNode = JSON.parse(JSON.stringify(pmDoc)) as PMNode; - (leftDoc.content?.[0]?.attrs as never).tabs = [{ pos: 96, align: 'left' }]; + (leftDoc.content?.[0]?.attrs as never).paragraphProperties = { tabStops: [{ pos: 96, align: 'left' }] }; const { blocks: leftBlocks } = toFlowBlocks(leftDoc); const leftMeasure = expectParagraphMeasure(await measureBlock(leftBlocks[0], 400)); @@ -509,8 +526,10 @@ describe('PM → FlowBlock → Measure integration', () => { { type: 'paragraph', attrs: { - shading: { - fill: 'AABBCC', + paragraphProperties: { + shading: { + fill: 'AABBCC', + }, }, }, content: [{ type: 'text', text: 'Shaded text' }], @@ -752,29 +771,14 @@ describe('page break integration tests', () => { type: 'paragraph', attrs: { paragraphProperties: { - type: 'element', - name: 'w:pPr', - elements: [ - { - type: 'element', - name: 'w:pStyle', - attributes: { - 'w:val': 'Footer', - }, - }, - { - type: 'element', - name: 'w:framePr', - attributes: { - 'w:wrap': 'none', - 'w:vAnchor': 'text', - 'w:hAnchor': 'margin', - 'w:xAlign': 'right', - // Note: w:y omitted because framePr.y applies a vertical offset to positioned frames. - // This test focuses on horizontal alignment (xAlign), not vertical positioning. - }, - }, - ], + styleId: 'Footer', + framePr: { + wrap: 'none', + vAnchor: 'text', + hAnchor: 'margin', + xAlign: 'right', + // Note: framePr.y omitted because it applies vertical offset to positioned frames. + }, }, }, content: [ @@ -830,7 +834,21 @@ describe('page break integration tests', () => { }); it('ensures DOCX pageBreakBefore paragraphs start on a new page', async () => { - const { blocks, layout } = await buildLayoutFromFixture(paragraphPPrVariationsFixture); + const fixture = JSON.parse(JSON.stringify(paragraphPPrVariationsFixture)) as PMNode; + fixture.content?.forEach((node) => { + if (node?.type !== 'paragraph') return; + const runs = (node.content ?? []).flatMap((child: PMNode) => (child.content ? child.content : [child])); + const hasTargetText = runs.some( + (run: PMNode) => typeof run.text === 'string' && run.text.includes('pageBreakBefore'), + ); + if (hasTargetText) { + node.attrs = { + ...(node.attrs ?? {}), + paragraphProperties: { pageBreakBefore: true }, + }; + } + }); + const { blocks, layout } = await buildLayoutFromFixture(fixture); const targetParagraphIndex = blocks.findIndex( (block) => diff --git a/packages/layout-engine/pm-adapter/src/internal.test.ts b/packages/layout-engine/pm-adapter/src/internal.test.ts index f11be05b5b..093882604f 100644 --- a/packages/layout-engine/pm-adapter/src/internal.test.ts +++ b/packages/layout-engine/pm-adapter/src/internal.test.ts @@ -789,28 +789,6 @@ describe('internal', () => { }); }); - describe('list counter context', () => { - it('should provide list counter methods to handlers', () => { - const doc: PMNode = { - type: 'doc', - content: [{ type: 'paragraph', content: [] }], - }; - - toFlowBlocks(doc); - - expect(handleParagraphNode).toHaveBeenCalledWith( - expect.any(Object), - expect.objectContaining({ - listCounterContext: { - getListCounter: expect.any(Function), - incrementListCounter: expect.any(Function), - resetListCounter: expect.any(Function), - }, - }), - ); - }); - }); - describe('converters', () => { it('should provide converter functions to handlers', () => { const doc: PMNode = { @@ -1111,7 +1089,6 @@ describe('internal', () => { context.defaultFont, context.defaultSize, context.styleContext, - undefined, context.trackedChangesConfig, context.bookmarks, context.hyperlinkConfig, @@ -1120,8 +1097,14 @@ describe('internal', () => { ); const lastCall = vi.mocked(paragraphToFlowBlocks).mock.calls.at(-1); - expect(lastCall?.[10]).toBe(themeColors); - expect(lastCall?.[12]).toBe(converterCtx); + expect(lastCall?.[9]).toBe(themeColors); + expect(lastCall?.[10]).toEqual( + expect.objectContaining({ + imageNodeToBlock: expect.any(Function), + tableNodeToBlock: expect.any(Function), + }), + ); + expect(lastCall?.[11]).toBe(converterCtx); }); }); }); diff --git a/packages/layout-engine/pm-adapter/src/internal.ts b/packages/layout-engine/pm-adapter/src/internal.ts index 4b6a421768..5cfe21b934 100644 --- a/packages/layout-engine/pm-adapter/src/internal.ts +++ b/packages/layout-engine/pm-adapter/src/internal.ts @@ -58,7 +58,6 @@ import type { PositionMap, NodeHandlerContext, NodeHandler, - ListCounterContext, PMDocumentMap, BatchAdapterOptions, ThemeColorPalette, @@ -223,7 +222,6 @@ export function toFlowBlocks(pmDoc: PMNode | object, options?: AdapterOptions): defaultFont: string, defaultSize: number, context: StyleContext, - listCounterContext?: ListCounterContext, trackedChanges?: TrackedChangesConfig, bookmarks?: Map, hyperlinkConfig?: HyperlinkConfig, @@ -237,7 +235,6 @@ export function toFlowBlocks(pmDoc: PMNode | object, options?: AdapterOptions): defaultFont, defaultSize, context, - listCounterContext, trackedChanges, bookmarks, hyperlinkConfig, @@ -273,7 +270,6 @@ export function toFlowBlocks(pmDoc: PMNode | object, options?: AdapterOptions): paragraphConverter, converterCtx ?? converterContext, { - listCounterContext: { getListCounter, incrementListCounter, resetListCounter }, converters: { paragraphToFlowBlocks: paragraphConverter, imageNodeToBlock, @@ -295,7 +291,6 @@ export function toFlowBlocks(pmDoc: PMNode | object, options?: AdapterOptions): defaultSize, styleContext, converterContext, - listCounterContext: { getListCounter, incrementListCounter, resetListCounter }, trackedChangesConfig, hyperlinkConfig, enableComments, @@ -445,7 +440,6 @@ function paragraphToFlowBlocks( defaultFont: string, defaultSize: number, styleContext: StyleContext, - listCounterContext?: ListCounterContext, trackedChanges?: TrackedChangesConfig, bookmarks?: Map, hyperlinkConfig: HyperlinkConfig = DEFAULT_HYPERLINK_CONFIG, @@ -460,7 +454,6 @@ function paragraphToFlowBlocks( defaultFont, defaultSize, styleContext, - listCounterContext, trackedChanges, bookmarks, hyperlinkConfig, @@ -499,7 +492,6 @@ function paragraphToFlowBlocks( paragraphToFlowBlocks, converterCtx ?? converterContext, { - listCounterContext, converters: { // Type assertion needed due to signature mismatch between actual function and type definition paragraphToFlowBlocks: paragraphToFlowBlocksImpl as unknown as ParagraphToFlowBlocksConverter, diff --git a/packages/layout-engine/pm-adapter/src/marks/application.ts b/packages/layout-engine/pm-adapter/src/marks/application.ts index 97129b0c01..dcce58bf19 100644 --- a/packages/layout-engine/pm-adapter/src/marks/application.ts +++ b/packages/layout-engine/pm-adapter/src/marks/application.ts @@ -588,6 +588,9 @@ export const collectTrackedChangeFromMarks = (marks?: PMMark[]): TrackedChangeMe * ``` */ export const normalizeUnderlineStyle = (value: unknown): UnderlineStyle | undefined => { + if (value === 'none') { + return undefined; + } if (value === undefined || value === null) { return 'single'; } diff --git a/packages/layout-engine/pm-adapter/src/sdt/document-index.ts b/packages/layout-engine/pm-adapter/src/sdt/document-index.ts index 74ecaff7cf..41dfcebbc3 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/document-index.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/document-index.ts @@ -48,7 +48,6 @@ export function handleIndexNode(node: PMNode, context: NodeHandlerContext): void defaultFont, defaultSize, styleContext, - listCounterContext, trackedChangesConfig, bookmarks, hyperlinkConfig, @@ -61,8 +60,6 @@ export function handleIndexNode(node: PMNode, context: NodeHandlerContext): void return; } - const { getListCounter, incrementListCounter, resetListCounter } = listCounterContext; - children.forEach((child) => { if (child.type !== 'paragraph') { return; @@ -89,7 +86,6 @@ export function handleIndexNode(node: PMNode, context: NodeHandlerContext): void defaultFont, defaultSize, styleContext, - { getListCounter, incrementListCounter, resetListCounter }, trackedChangesConfig, bookmarks, hyperlinkConfig, diff --git a/packages/layout-engine/pm-adapter/src/sdt/document-part-object.ts b/packages/layout-engine/pm-adapter/src/sdt/document-part-object.ts index 2b5a7085d6..c37400408c 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/document-part-object.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/document-part-object.ts @@ -31,7 +31,6 @@ export function handleDocumentPartObjectNode(node: PMNode, context: NodeHandlerC bookmarks, hyperlinkConfig, converters, - listCounterContext, trackedChangesConfig, } = context; const docPartGallery = getDocPartGallery(node); @@ -67,7 +66,6 @@ export function handleDocumentPartObjectNode(node: PMNode, context: NodeHandlerC defaultFont, defaultSize, styleContext, - listCounterContext, trackedChangesConfig, bookmarks, hyperlinkConfig, diff --git a/packages/layout-engine/pm-adapter/src/sdt/document-section.test.ts b/packages/layout-engine/pm-adapter/src/sdt/document-section.test.ts index 700b0d4f15..082017c7be 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/document-section.test.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/document-section.test.ts @@ -36,11 +36,6 @@ describe('document-section', () => { styles: new Map(), numbering: new Map(), }; - const mockListCounterContext = { - getListCounter: vi.fn(), - incrementListCounter: vi.fn(), - resetListCounter: vi.fn(), - }; const mockHyperlinkConfig = { enableRichHyperlinks: false, }; @@ -71,7 +66,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -114,7 +108,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -161,7 +154,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -200,7 +192,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -234,7 +225,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -269,7 +259,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -287,11 +276,6 @@ describe('document-section', () => { 'Arial', 12, mockStyleContext, - expect.objectContaining({ - getListCounter: mockListCounterContext.getListCounter, - incrementListCounter: mockListCounterContext.incrementListCounter, - resetListCounter: mockListCounterContext.resetListCounter, - }), undefined, undefined, mockHyperlinkConfig, @@ -319,7 +303,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -354,7 +337,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -388,7 +370,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -424,7 +405,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -474,7 +454,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -518,7 +497,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -551,7 +529,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -587,7 +564,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -621,7 +597,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -653,7 +628,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -702,7 +676,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -750,7 +723,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -797,7 +769,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -840,7 +811,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -875,7 +845,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -922,7 +891,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -970,7 +938,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -1023,7 +990,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -1081,7 +1047,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -1138,7 +1103,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -1176,7 +1140,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -1217,7 +1180,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -1259,7 +1221,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -1315,7 +1276,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -1365,7 +1325,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -1415,7 +1374,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -1459,7 +1417,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -1503,7 +1460,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, bookmarks: mockBookmarks, hyperlinkConfig: mockHyperlinkConfig, }, @@ -1516,16 +1472,15 @@ describe('document-section', () => { ); expect(mockParagraphConverter).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.anything(), - expect.anything(), - expect.anything(), - expect.anything(), - expect.anything(), + children[0], + mockBlockIdGenerator, + mockPositionMap, + 'Arial', + 12, + mockStyleContext, undefined, mockBookmarks, - expect.anything(), + mockHyperlinkConfig, ); }); @@ -1549,7 +1504,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -1584,7 +1538,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -1625,7 +1578,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -1661,7 +1613,6 @@ describe('document-section', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, diff --git a/packages/layout-engine/pm-adapter/src/sdt/document-section.ts b/packages/layout-engine/pm-adapter/src/sdt/document-section.ts index 785a81fcf3..544291e540 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/document-section.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/document-section.ts @@ -11,7 +11,6 @@ import type { BlockIdGenerator, PositionMap, StyleContext, - ListCounterContext, HyperlinkConfig, NodeHandlerContext, TrackedChangesConfig, @@ -37,7 +36,6 @@ type ParagraphConverter = ( defaultFont: string, defaultSize: number, styleContext: StyleContext, - listCounterContext?: ListCounterContext, trackedChanges?: TrackedChangesConfig, bookmarks?: Map, hyperlinkConfig?: HyperlinkConfig, @@ -78,7 +76,6 @@ interface ProcessingContext { defaultFont: string; defaultSize: number; styleContext: StyleContext; - listCounterContext: ListCounterContext; bookmarks?: Map; hyperlinkConfig: HyperlinkConfig; } @@ -117,7 +114,6 @@ function processParagraphChild( output: ProcessingOutput, converters: NodeConverters, ): void { - const { getListCounter, incrementListCounter, resetListCounter } = context.listCounterContext; const paragraphBlocks = converters.paragraphToFlowBlocks( child, context.nextBlockId, @@ -125,7 +121,6 @@ function processParagraphChild( context.defaultFont, context.defaultSize, context.styleContext, - { getListCounter, incrementListCounter, resetListCounter }, undefined, // trackedChanges context.bookmarks, context.hyperlinkConfig, @@ -222,7 +217,6 @@ function processNestedStructuredContent( output: ProcessingOutput, converters: NodeConverters, ): void { - const { getListCounter, incrementListCounter, resetListCounter } = context.listCounterContext; // Nested structured content block inside section - unwrap and chain metadata const nestedMetadata = resolveNodeSdtMetadata(child, 'structuredContentBlock'); child.content?.forEach((grandchild) => { @@ -234,7 +228,6 @@ function processNestedStructuredContent( context.defaultFont, context.defaultSize, context.styleContext, - { getListCounter, incrementListCounter, resetListCounter }, undefined, // trackedChanges context.bookmarks, context.hyperlinkConfig, @@ -287,7 +280,6 @@ function processDocumentPartObject( output: ProcessingOutput, converters: NodeConverters, ): void { - const { getListCounter, incrementListCounter, resetListCounter } = context.listCounterContext; // Nested doc part (e.g., TOC) inside section const docPartGallery = getDocPartGallery(child); const docPartObjectId = getDocPartObjectId(child); @@ -307,7 +299,6 @@ function processDocumentPartObject( defaultFont: context.defaultFont, defaultSize: context.defaultSize, styleContext: context.styleContext, - listCounterContext: { getListCounter, incrementListCounter, resetListCounter }, bookmarks: context.bookmarks, hyperlinkConfig: context.hyperlinkConfig, }, @@ -392,7 +383,6 @@ export function handleDocumentSectionNode(node: PMNode, context: NodeHandlerCont defaultFont, defaultSize, styleContext, - listCounterContext, bookmarks, hyperlinkConfig, converters, @@ -415,7 +405,6 @@ export function handleDocumentSectionNode(node: PMNode, context: NodeHandlerCont defaultFont, defaultSize, styleContext, - listCounterContext, bookmarks, hyperlinkConfig, }, diff --git a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts index d56c4824ee..7680d914d1 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.test.ts @@ -26,11 +26,6 @@ describe('structured-content-block', () => { styles: new Map(), numbering: new Map(), }; - const mockListCounterContext = { - getListCounter: vi.fn(), - incrementListCounter: vi.fn(), - resetListCounter: vi.fn(), - }; const mockHyperlinkConfig = { enableRichHyperlinks: false, }; @@ -66,7 +61,6 @@ describe('structured-content-block', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, trackedChangesConfig: mockTrackedChangesConfig, bookmarks: mockBookmarks, hyperlinkConfig: mockHyperlinkConfig, @@ -99,7 +93,6 @@ describe('structured-content-block', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, trackedChangesConfig: mockTrackedChangesConfig, bookmarks: mockBookmarks, hyperlinkConfig: mockHyperlinkConfig, @@ -132,7 +125,6 @@ describe('structured-content-block', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, trackedChangesConfig: mockTrackedChangesConfig, bookmarks: mockBookmarks, hyperlinkConfig: mockHyperlinkConfig, @@ -175,7 +167,6 @@ describe('structured-content-block', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, trackedChangesConfig: mockTrackedChangesConfig, bookmarks: mockBookmarks, hyperlinkConfig: mockHyperlinkConfig, @@ -224,7 +215,6 @@ describe('structured-content-block', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, trackedChangesConfig: mockTrackedChangesConfig, bookmarks: mockBookmarks, hyperlinkConfig: mockHyperlinkConfig, @@ -262,7 +252,6 @@ describe('structured-content-block', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, trackedChangesConfig: mockTrackedChangesConfig, bookmarks: mockBookmarks, hyperlinkConfig: mockHyperlinkConfig, @@ -301,7 +290,6 @@ describe('structured-content-block', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, trackedChangesConfig: mockTrackedChangesConfig, bookmarks: mockBookmarks, hyperlinkConfig: mockHyperlinkConfig, @@ -319,11 +307,6 @@ describe('structured-content-block', () => { 'Arial', 12, mockStyleContext, - expect.objectContaining({ - getListCounter: mockListCounterContext.getListCounter, - incrementListCounter: mockListCounterContext.incrementListCounter, - resetListCounter: mockListCounterContext.resetListCounter, - }), mockTrackedChangesConfig, mockBookmarks, mockHyperlinkConfig, @@ -356,7 +339,6 @@ describe('structured-content-block', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, trackedChangesConfig: mockTrackedChangesConfig, bookmarks: mockBookmarks, hyperlinkConfig: mockHyperlinkConfig, @@ -396,7 +378,6 @@ describe('structured-content-block', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, trackedChangesConfig: mockTrackedChangesConfig, bookmarks: mockBookmarks, hyperlinkConfig: mockHyperlinkConfig, @@ -436,7 +417,6 @@ describe('structured-content-block', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, trackedChangesConfig: mockTrackedChangesConfig, bookmarks: mockBookmarks, hyperlinkConfig: mockHyperlinkConfig, @@ -482,7 +462,6 @@ describe('structured-content-block', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, trackedChangesConfig: mockTrackedChangesConfig, bookmarks: mockBookmarks, hyperlinkConfig: mockHyperlinkConfig, @@ -518,7 +497,6 @@ describe('structured-content-block', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, trackedChangesConfig: mockTrackedChangesConfig, bookmarks: mockBookmarks, hyperlinkConfig: mockHyperlinkConfig, @@ -562,7 +540,6 @@ describe('structured-content-block', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, trackedChangesConfig: mockTrackedChangesConfig, bookmarks: mockBookmarks, hyperlinkConfig: mockHyperlinkConfig, @@ -598,7 +575,6 @@ describe('structured-content-block', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, trackedChangesConfig: mockTrackedChangesConfig, bookmarks: mockBookmarks, hyperlinkConfig: mockHyperlinkConfig, @@ -643,7 +619,6 @@ describe('structured-content-block', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, trackedChangesConfig: mockTrackedChangesConfig, bookmarks: mockBookmarks, hyperlinkConfig: mockHyperlinkConfig, @@ -687,7 +662,6 @@ describe('structured-content-block', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, trackedChangesConfig: mockTrackedChangesConfig, bookmarks: mockBookmarks, hyperlinkConfig: mockHyperlinkConfig, @@ -728,7 +702,6 @@ describe('structured-content-block', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, trackedChangesConfig: mockTrackedChangesConfig, bookmarks: mockBookmarks, hyperlinkConfig: mockHyperlinkConfig, @@ -765,7 +738,6 @@ describe('structured-content-block', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, trackedChangesConfig: mockTrackedChangesConfig, bookmarks: mockBookmarks, hyperlinkConfig: mockHyperlinkConfig, @@ -801,7 +773,6 @@ describe('structured-content-block', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, trackedChangesConfig: mockTrackedChangesConfig, bookmarks: mockBookmarks, hyperlinkConfig: mockHyperlinkConfig, @@ -845,7 +816,6 @@ describe('structured-content-block', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, trackedChangesConfig: mockTrackedChangesConfig, bookmarks: mockBookmarks, hyperlinkConfig: mockHyperlinkConfig, @@ -889,7 +859,6 @@ describe('structured-content-block', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, trackedChangesConfig: mockTrackedChangesConfig, bookmarks: mockBookmarks, hyperlinkConfig: mockHyperlinkConfig, diff --git a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts index eaa6f8a13d..8cf65df41f 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/structured-content-block.ts @@ -27,13 +27,11 @@ export function handleStructuredContentBlockNode(node: PMNode, context: NodeHand defaultFont, defaultSize, styleContext, - listCounterContext, trackedChangesConfig, bookmarks, hyperlinkConfig, converters, } = context; - const { getListCounter, incrementListCounter, resetListCounter } = listCounterContext; const structuredContentMetadata = resolveNodeSdtMetadata(node, 'structuredContentBlock'); const paragraphToFlowBlocks = converters?.paragraphToFlowBlocks; @@ -50,7 +48,6 @@ export function handleStructuredContentBlockNode(node: PMNode, context: NodeHand defaultFont, defaultSize, styleContext, - { getListCounter, incrementListCounter, resetListCounter }, trackedChangesConfig, bookmarks, hyperlinkConfig, diff --git a/packages/layout-engine/pm-adapter/src/sdt/toc.test.ts b/packages/layout-engine/pm-adapter/src/sdt/toc.test.ts index b4a0f62caa..52d6b95298 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/toc.test.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/toc.test.ts @@ -149,11 +149,6 @@ describe('toc', () => { styles: new Map(), numbering: new Map(), }; - const mockListCounterContext = { - getListCounter: vi.fn(), - incrementListCounter: vi.fn(), - resetListCounter: vi.fn(), - }; const mockHyperlinkConfig = { mode: 'preserve' as const, }; @@ -197,7 +192,6 @@ describe('toc', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -253,7 +247,6 @@ describe('toc', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -307,7 +300,6 @@ describe('toc', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -359,7 +351,6 @@ describe('toc', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -416,7 +407,6 @@ describe('toc', () => { defaultFont: 'Arial', defaultSize: 12, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, hyperlinkConfig: mockHyperlinkConfig, }, { blocks, recordBlockKind }, @@ -464,7 +454,6 @@ describe('toc', () => { defaultFont: 'Calibri', defaultSize: 14, styleContext: mockStyleContext, - listCounterContext: mockListCounterContext, bookmarks: mockBookmarks, trackedChanges: mockTrackedChanges, hyperlinkConfig: mockHyperlinkConfig, @@ -480,7 +469,6 @@ describe('toc', () => { 'Calibri', 14, mockStyleContext, - mockListCounterContext, mockTrackedChanges, mockBookmarks, mockHyperlinkConfig, diff --git a/packages/layout-engine/pm-adapter/src/sdt/toc.ts b/packages/layout-engine/pm-adapter/src/sdt/toc.ts index 73b44d0d96..4c36d86ad4 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/toc.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/toc.ts @@ -11,7 +11,6 @@ import type { BlockIdGenerator, PositionMap, StyleContext, - ListCounterContext, HyperlinkConfig, TrackedChangesConfig, NodeHandlerContext, @@ -96,7 +95,6 @@ export function processTocChildren( defaultFont: string; defaultSize: number; styleContext: StyleContext; - listCounterContext?: ListCounterContext; bookmarks?: Map; trackedChanges?: TrackedChangesConfig; hyperlinkConfig: HyperlinkConfig; @@ -112,7 +110,6 @@ export function processTocChildren( defaultFont: string, defaultSize: number, styleContext: StyleContext, - listCounterContext?: ListCounterContext, trackedChanges?: TrackedChangesConfig, bookmarks?: Map, hyperlinkConfig?: HyperlinkConfig, @@ -131,7 +128,6 @@ export function processTocChildren( context.defaultFont, context.defaultSize, context.styleContext, - context.listCounterContext, context.trackedChanges, context.bookmarks, context.hyperlinkConfig, @@ -185,13 +181,11 @@ export function handleTableOfContentsNode(node: PMNode, context: NodeHandlerCont defaultFont, defaultSize, styleContext, - listCounterContext, trackedChangesConfig, bookmarks, hyperlinkConfig, converters, } = context; - const { getListCounter, incrementListCounter, resetListCounter } = listCounterContext; const tocInstruction = getNodeInstruction(node); const paragraphToFlowBlocks = converters?.paragraphToFlowBlocks; if (!paragraphToFlowBlocks) { @@ -207,7 +201,6 @@ export function handleTableOfContentsNode(node: PMNode, context: NodeHandlerCont defaultFont, defaultSize, styleContext, - { getListCounter, incrementListCounter, resetListCounter }, trackedChangesConfig, bookmarks, hyperlinkConfig, diff --git a/packages/layout-engine/pm-adapter/src/types.d.ts b/packages/layout-engine/pm-adapter/src/types.d.ts index cf0d4ba803..94988a9af4 100644 --- a/packages/layout-engine/pm-adapter/src/types.d.ts +++ b/packages/layout-engine/pm-adapter/src/types.d.ts @@ -235,7 +235,6 @@ export interface NodeHandlerContext { defaultSize: number; styleContext: StyleContext; converterContext?: ConverterContext; - listCounterContext: ListCounterContext; trackedChangesConfig: TrackedChangesConfig; hyperlinkConfig: HyperlinkConfig; bookmarks: Map; @@ -252,7 +251,6 @@ export interface NodeHandlerContext { defaultFont: string, defaultSize: number, styleContext: StyleContext, - listCounterContext?: ListCounterContext, trackedChanges?: TrackedChangesConfig, bookmarks?: Map, hyperlinkConfig?: HyperlinkConfig, diff --git a/packages/layout-engine/pm-adapter/src/types.ts b/packages/layout-engine/pm-adapter/src/types.ts index 43021c1160..5444c6c70d 100644 --- a/packages/layout-engine/pm-adapter/src/types.ts +++ b/packages/layout-engine/pm-adapter/src/types.ts @@ -3,17 +3,12 @@ */ import type { TrackedChangesMode, SectionMetadata, FlowBlock, TrackedChangeMeta, Engines } from '@superdoc/contracts'; -import type { - StyleContext as StyleEngineContext, - StyleNode as StyleEngineNode, - ComputedParagraphStyle, -} from '@superdoc/style-engine'; +import type { StyleContext as StyleEngineContext, ComputedParagraphStyle } from '@superdoc/style-engine'; import type { SectionRange } from './sections/index.js'; import type { ConverterContext } from './converter-context.js'; export type { ConverterContext } from './converter-context.js'; export type StyleContext = StyleEngineContext; -export type StyleNode = StyleEngineNode; export type { ComputedParagraphStyle }; export type ThemeColorPalette = Record; @@ -280,9 +275,6 @@ export interface NodeHandlerContext { styleContext: StyleContext; converterContext?: ConverterContext; - // List counters - listCounterContext: ListCounterContext; - // Tracked changes & hyperlinks trackedChangesConfig: TrackedChangesConfig; hyperlinkConfig: HyperlinkConfig; @@ -313,11 +305,6 @@ export type NodeHandler = (node: PMNode, context: NodeHandlerContext) => void; /** * List counter context for numbering */ -export type ListCounterContext = { - getListCounter: (numId: number, ilvl: number) => number; - incrementListCounter: (numId: number, ilvl: number) => number; - resetListCounter: (numId: number, ilvl: number) => void; -}; export type ParagraphToFlowBlocksConverter = ( para: PMNode, @@ -326,7 +313,6 @@ export type ParagraphToFlowBlocksConverter = ( defaultFont: string, defaultSize: number, styleContext: StyleContext, - listCounterContext?: ListCounterContext, trackedChanges?: TrackedChangesConfig, bookmarks?: Map, hyperlinkConfig?: HyperlinkConfig, @@ -349,7 +335,6 @@ export type DrawingNodeToBlockConverter = ( ) => FlowBlock | null; export type TableNodeToBlockOptions = { - listCounterContext?: ListCounterContext; converters?: NestedConverters; }; @@ -384,11 +369,11 @@ export type NestedConverters = { * List rendering attributes */ export type ListRenderingAttrs = { - markerText?: string; - justification?: 'left' | 'right' | 'center'; - path?: number[]; - numberingType?: string; - suffix?: 'tab' | 'space' | 'nothing'; + markerText: string; + justification: 'left' | 'right' | 'center'; + path: number[]; + numberingType: string; + suffix: 'tab' | 'space' | 'nothing'; }; /** diff --git a/packages/layout-engine/style-engine/src/cascade.test.ts b/packages/layout-engine/style-engine/src/cascade.test.ts index d480043327..870f933270 100644 --- a/packages/layout-engine/style-engine/src/cascade.test.ts +++ b/packages/layout-engine/style-engine/src/cascade.test.ts @@ -1,17 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { - combineProperties, - combineRunProperties, - applyInlineOverrides, - isValidFontSize, - resolveFontSizeWithFallback, - orderDefaultsAndNormal, - createFirstLineIndentHandler, - createHangingIndentHandler, - combineIndentProperties, - DEFAULT_FONT_SIZE_HALF_POINTS, - INLINE_OVERRIDE_PROPERTIES, -} from './cascade.js'; +import { combineProperties, combineRunProperties, orderDefaultsAndNormal, combineIndentProperties } from './cascade.js'; describe('cascade - combineProperties', () => { it('returns empty object when propertiesArray is empty', () => { @@ -166,213 +154,6 @@ describe('cascade - combineRunProperties', () => { }); }); -describe('cascade - applyInlineOverrides', () => { - it('applies inline overrides for INLINE_OVERRIDE_PROPERTIES', () => { - const finalProps = { fontSize: 22, bold: true, color: 'FF0000' }; - const inlineProps = { fontSize: 24, italic: true }; - const result = applyInlineOverrides(finalProps, inlineProps); - expect(result.fontSize).toBe(24); - expect(result.bold).toBe(true); - expect(result.italic).toBe(true); - }); - - it('returns finalProps unchanged when inlineProps is null', () => { - const finalProps = { fontSize: 22, bold: true }; - const result = applyInlineOverrides(finalProps, null); - expect(result).toBe(finalProps); - expect(result).toEqual({ fontSize: 22, bold: true }); - }); - - it('returns finalProps unchanged when inlineProps is undefined', () => { - const finalProps = { fontSize: 22, bold: true }; - const result = applyInlineOverrides(finalProps, undefined); - expect(result).toBe(finalProps); - }); - - it('only overrides properties in overrideKeys list', () => { - const finalProps = { fontSize: 22, bold: true, color: 'FF0000' }; - const inlineProps = { fontSize: 24, bold: false, color: '00FF00' }; - const result = applyInlineOverrides(finalProps, inlineProps); - // fontSize, bold are in INLINE_OVERRIDE_PROPERTIES, color is not - expect(result.fontSize).toBe(24); - expect(result.bold).toBe(false); - expect(result.color).toBe('FF0000'); // Not overridden - }); - - it('respects custom overrideKeys parameter', () => { - const finalProps = { fontSize: 22, bold: true, color: 'FF0000' }; - const inlineProps = { fontSize: 24, bold: false, color: '00FF00' }; - const result = applyInlineOverrides(finalProps, inlineProps, ['color']); - expect(result.fontSize).toBe(22); // Not in custom override list - expect(result.bold).toBe(true); // Not in custom override list - expect(result.color).toBe('00FF00'); // In custom override list - }); - - it('does not override when inline property is null', () => { - const finalProps = { fontSize: 22, bold: true }; - const inlineProps = { fontSize: null, italic: true }; - const result = applyInlineOverrides(finalProps, inlineProps); - expect(result.fontSize).toBe(22); // Not overridden (null check) - expect(result.bold).toBe(true); - }); - - it('does not override when inline property is undefined', () => { - const finalProps = { fontSize: 22, bold: true }; - const inlineProps = { fontSize: undefined, italic: true }; - const result = applyInlineOverrides(finalProps, inlineProps); - expect(result.fontSize).toBe(22); // Not overridden (undefined check) - }); - - it('overrides with falsy values (false, 0, empty string)', () => { - const finalProps = { bold: true, fontSize: 22, letterSpacing: 10 }; - const inlineProps = { bold: false, fontSize: 0, letterSpacing: 0 }; - const result = applyInlineOverrides(finalProps, inlineProps); - expect(result.bold).toBe(false); // Falsy but valid - expect(result.fontSize).toBe(0); // Zero is valid - expect(result.letterSpacing).toBe(0); - }); - - it('mutates and returns the same finalProps object', () => { - const finalProps = { fontSize: 22 }; - const inlineProps = { fontSize: 24 }; - const result = applyInlineOverrides(finalProps, inlineProps); - expect(result).toBe(finalProps); // Same object reference - expect(finalProps.fontSize).toBe(24); // Mutated - }); - - it('applies all INLINE_OVERRIDE_PROPERTIES correctly', () => { - const finalProps = {}; - const inlineProps = { - fontSize: 24, - bold: true, - italic: true, - strike: true, - underline: { type: 'single' }, - letterSpacing: 20, - }; - const result = applyInlineOverrides(finalProps, inlineProps); - expect(result).toEqual(inlineProps); - }); -}); - -describe('cascade - isValidFontSize', () => { - it('returns true for positive finite numbers', () => { - expect(isValidFontSize(1)).toBe(true); - expect(isValidFontSize(20)).toBe(true); - expect(isValidFontSize(100.5)).toBe(true); - expect(isValidFontSize(0.1)).toBe(true); - }); - - it('returns false for zero', () => { - expect(isValidFontSize(0)).toBe(false); - }); - - it('returns false for negative numbers', () => { - expect(isValidFontSize(-1)).toBe(false); - expect(isValidFontSize(-20)).toBe(false); - }); - - it('returns false for NaN', () => { - expect(isValidFontSize(NaN)).toBe(false); - }); - - it('returns false for Infinity', () => { - expect(isValidFontSize(Infinity)).toBe(false); - expect(isValidFontSize(-Infinity)).toBe(false); - }); - - it('returns false for strings', () => { - expect(isValidFontSize('20')).toBe(false); - expect(isValidFontSize('abc')).toBe(false); - }); - - it('returns false for null', () => { - expect(isValidFontSize(null)).toBe(false); - }); - - it('returns false for undefined', () => { - expect(isValidFontSize(undefined)).toBe(false); - }); - - it('returns false for objects', () => { - expect(isValidFontSize({})).toBe(false); - expect(isValidFontSize({ fontSize: 20 })).toBe(false); - }); - - it('returns false for arrays', () => { - expect(isValidFontSize([])).toBe(false); - expect(isValidFontSize([20])).toBe(false); - }); - - it('returns false for boolean values', () => { - expect(isValidFontSize(true)).toBe(false); - expect(isValidFontSize(false)).toBe(false); - }); -}); - -describe('cascade - resolveFontSizeWithFallback', () => { - it('returns value when valid', () => { - expect(resolveFontSizeWithFallback(24)).toBe(24); - expect(resolveFontSizeWithFallback(10.5)).toBe(10.5); - }); - - it('falls back to defaultProps.fontSize when value is invalid', () => { - expect(resolveFontSizeWithFallback(null, { fontSize: 22 })).toBe(22); - expect(resolveFontSizeWithFallback(undefined, { fontSize: 22 })).toBe(22); - expect(resolveFontSizeWithFallback(0, { fontSize: 22 })).toBe(22); - expect(resolveFontSizeWithFallback(-10, { fontSize: 22 })).toBe(22); - }); - - it('falls back to normalProps.fontSize when value and defaults are invalid', () => { - expect(resolveFontSizeWithFallback(null, null, { fontSize: 20 })).toBe(20); - expect(resolveFontSizeWithFallback(null, {}, { fontSize: 20 })).toBe(20); - expect(resolveFontSizeWithFallback(0, { fontSize: 0 }, { fontSize: 20 })).toBe(20); - }); - - it('falls back to DEFAULT_FONT_SIZE_HALF_POINTS when all sources invalid', () => { - expect(resolveFontSizeWithFallback(null)).toBe(DEFAULT_FONT_SIZE_HALF_POINTS); - expect(resolveFontSizeWithFallback(null, null, null)).toBe(DEFAULT_FONT_SIZE_HALF_POINTS); - expect(resolveFontSizeWithFallback(0, {}, {})).toBe(DEFAULT_FONT_SIZE_HALF_POINTS); - expect(resolveFontSizeWithFallback(NaN, { fontSize: NaN }, { fontSize: 0 })).toBe(DEFAULT_FONT_SIZE_HALF_POINTS); - }); - - it('prefers value over defaults', () => { - expect(resolveFontSizeWithFallback(24, { fontSize: 22 }, { fontSize: 20 })).toBe(24); - }); - - it('prefers defaults over Normal', () => { - expect(resolveFontSizeWithFallback(null, { fontSize: 22 }, { fontSize: 20 })).toBe(22); - }); - - it('prefers Normal over constant', () => { - expect(resolveFontSizeWithFallback(null, null, { fontSize: 18 })).toBe(18); - }); - - it('handles defaultProps as null/undefined', () => { - expect(resolveFontSizeWithFallback(null, null, { fontSize: 20 })).toBe(20); - expect(resolveFontSizeWithFallback(null, undefined, { fontSize: 20 })).toBe(20); - }); - - it('handles normalProps as null/undefined', () => { - expect(resolveFontSizeWithFallback(null, { fontSize: 22 }, null)).toBe(22); - expect(resolveFontSizeWithFallback(null, { fontSize: 22 }, undefined)).toBe(22); - }); - - it('handles invalid fontSize in defaultProps', () => { - expect(resolveFontSizeWithFallback(null, { fontSize: 'invalid' }, { fontSize: 20 })).toBe(20); - expect(resolveFontSizeWithFallback(null, { fontSize: null }, { fontSize: 20 })).toBe(20); - }); - - it('handles invalid fontSize in normalProps', () => { - expect(resolveFontSizeWithFallback(null, { fontSize: 22 }, { fontSize: 'invalid' })).toBe(22); - expect(resolveFontSizeWithFallback(null, null, { fontSize: -5 })).toBe(DEFAULT_FONT_SIZE_HALF_POINTS); - }); - - it('validates that DEFAULT_FONT_SIZE_HALF_POINTS is 20', () => { - expect(DEFAULT_FONT_SIZE_HALF_POINTS).toBe(20); - }); -}); - describe('cascade - orderDefaultsAndNormal', () => { const defaultProps = { fontSize: 22, bold: true }; const normalProps = { fontSize: 20, italic: true }; @@ -414,113 +195,6 @@ describe('cascade - orderDefaultsAndNormal', () => { }); }); -describe('cascade - createFirstLineIndentHandler', () => { - it('returns a function', () => { - const handler = createFirstLineIndentHandler(); - expect(typeof handler).toBe('function'); - }); - - it('removes hanging from target when source has firstLine', () => { - const handler = createFirstLineIndentHandler(); - const target = { hanging: 360, left: 720 }; - const source = { firstLine: 432 }; - const result = handler(target, source); - expect(result).toBe(432); - expect(target.hanging).toBeUndefined(); - expect(target.left).toBe(720); // Preserved - }); - - it('does not remove hanging when source has no firstLine', () => { - const handler = createFirstLineIndentHandler(); - const target = { hanging: 360 }; - const source = { left: 720 }; - const result = handler(target, source); - expect(result).toBeUndefined(); // source.firstLine is undefined - expect(target.hanging).toBe(360); // Preserved - }); - - it('does not fail when target has no hanging', () => { - const handler = createFirstLineIndentHandler(); - const target = { left: 720 }; - const source = { firstLine: 432 }; - const result = handler(target, source); - expect(result).toBe(432); - expect(target.hanging).toBeUndefined(); - }); - - it('returns source.firstLine value', () => { - const handler = createFirstLineIndentHandler(); - const target = {}; - const source = { firstLine: 432 }; - const result = handler(target, source); - expect(result).toBe(432); - }); - - it('handles null hanging in target', () => { - const handler = createFirstLineIndentHandler(); - const target = { hanging: null }; - const source = { firstLine: 432 }; - const result = handler(target, source); - expect(result).toBe(432); - // Null is falsy, so delete won't happen in this case - }); - - it('handles zero values', () => { - const handler = createFirstLineIndentHandler(); - const target = { hanging: 360 }; - const source = { firstLine: 0 }; - const result = handler(target, source); - expect(result).toBe(0); - // 0 is falsy but the condition checks != null, so firstLine: 0 is valid - // But the actual implementation checks if (target.hanging != null && source.firstLine != null) - // Since 0 != null is true, hanging should be deleted - expect(target.hanging).toBeUndefined(); - }); - - it('handles negative firstLine values', () => { - const handler = createFirstLineIndentHandler(); - const target = { hanging: 360 }; - const source = { firstLine: -200 }; - const result = handler(target, source); - expect(result).toBe(-200); - expect(target.hanging).toBeUndefined(); // Negative is truthy - }); - - it('mutates the target object', () => { - const handler = createFirstLineIndentHandler(); - const target = { hanging: 360, left: 720 }; - const source = { firstLine: 432 }; - handler(target, source); - expect(target.hanging).toBeUndefined(); // Mutated - }); -}); - -describe('cascade - createHangingIndentHandler', () => { - it('returns a function', () => { - const handler = createHangingIndentHandler(); - expect(typeof handler).toBe('function'); - }); - - it('removes firstLine from target when source has hanging', () => { - const handler = createHangingIndentHandler(); - const target = { firstLine: 432, left: 720 }; - const source = { hanging: 360 }; - const result = handler(target, source); - expect(result).toBe(360); - expect(target.firstLine).toBeUndefined(); - expect(target.left).toBe(720); // Preserved - }); - - it('does not remove firstLine when source has no hanging', () => { - const handler = createHangingIndentHandler(); - const target = { firstLine: 432 }; - const source = { left: 720 }; - const result = handler(target, source); - expect(result).toBeUndefined(); // source.hanging is undefined - expect(target.firstLine).toBe(432); // Preserved - }); -}); - describe('cascade - combineIndentProperties', () => { it('extracts and combines indent properties from objects', () => { const result = combineIndentProperties([{ indent: { left: 720 } }, { indent: { left: 1440, hanging: 360 } }]); @@ -615,23 +289,3 @@ describe('cascade - combineIndentProperties', () => { }); }); }); - -describe('cascade - INLINE_OVERRIDE_PROPERTIES constant', () => { - it('contains expected properties', () => { - expect(INLINE_OVERRIDE_PROPERTIES).toContain('fontSize'); - expect(INLINE_OVERRIDE_PROPERTIES).toContain('bold'); - expect(INLINE_OVERRIDE_PROPERTIES).toContain('italic'); - expect(INLINE_OVERRIDE_PROPERTIES).toContain('strike'); - expect(INLINE_OVERRIDE_PROPERTIES).toContain('underline'); - expect(INLINE_OVERRIDE_PROPERTIES).toContain('letterSpacing'); - }); - - it('has exactly 6 properties', () => { - expect(INLINE_OVERRIDE_PROPERTIES).toHaveLength(6); - }); - - it('is a readonly array', () => { - // TypeScript enforces readonly at compile time, but we can verify it's an array - expect(Array.isArray(INLINE_OVERRIDE_PROPERTIES)).toBe(true); - }); -}); diff --git a/packages/layout-engine/style-engine/src/cascade.ts b/packages/layout-engine/style-engine/src/cascade.ts index cd2a4c00cb..054eb63dba 100644 --- a/packages/layout-engine/style-engine/src/cascade.ts +++ b/packages/layout-engine/style-engine/src/cascade.ts @@ -10,77 +10,9 @@ * - layout-engine's style resolution (for rendering) */ -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- +import { ParagraphProperties, RunFontFamilyProperties, RunProperties } from './ooxml/types'; -/** - * Properties that must be explicitly overridden by inline formatting. - * These properties require special handling because inline w:rPr formatting must - * always take precedence over character style (w:rStyle) properties, even though - * both are merged in the style chain. This explicit override ensures that direct - * formatting (e.g., w:sz for fontSize) always wins over linked character styles. - * - * Note: fontFamily and color are already handled by combineProperties with full override logic. - */ -export const INLINE_OVERRIDE_PROPERTIES = [ - 'fontSize', - 'bold', - 'italic', - 'strike', - 'underline', - 'letterSpacing', -] as const; - -/** - * Default font size in half-points (20 half-points = 10pt). - * This baseline ensures all text has a valid, positive font size when no other source provides one. - * Used as the final fallback in fontSize resolution cascade: - * 1. Inline formatting (highest priority) - * 2. Character style - * 3. Paragraph style - * 4. Document defaults - * 5. Normal style - * 6. DEFAULT_FONT_SIZE_HALF_POINTS (this constant) - */ -export const DEFAULT_FONT_SIZE_HALF_POINTS = 20; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export type PropertyObject = Record; - -export type SpecialHandler = (target: PropertyObject, source: PropertyObject) => T; - -export interface CombinePropertiesOptions { - /** - * Keys that should completely overwrite instead of deep merge. - * Use this for complex objects like fontFamily or color that should - * be replaced entirely rather than merged property-by-property. - */ - fullOverrideProps?: string[]; - - /** - * Custom merge handlers for specific keys. - * The handler receives the accumulated target and current source, - * and returns the new value for that key. - */ - specialHandling?: Record; -} - -// --------------------------------------------------------------------------- -// Core Cascade Functions -// --------------------------------------------------------------------------- - -/** - * Determines whether the supplied value is a mergeable plain object. - * @param item - Value to inspect. - * @returns True when the value is a non-array object. - */ -function isObject(item: unknown): item is PropertyObject { - return item != null && typeof item === 'object' && !Array.isArray(item); -} +export type PropertyObject = ParagraphProperties | RunProperties; /** * Performs a deep merge on an ordered list of property objects. @@ -113,21 +45,35 @@ function isObject(item: unknown): item is PropertyObject { * // result2: { color: { val: '00FF00' } } - NOT merged * ``` */ -export function combineProperties( - propertiesArray: PropertyObject[], - options: CombinePropertiesOptions = {}, -): PropertyObject { +export function combineProperties( + propertiesArray: T[], + options: { + /** + * Keys that should completely overwrite instead of deep merge. + * Use this for complex objects like fontFamily or color that should + * be replaced entirely rather than merged property-by-property. + */ + fullOverrideProps?: string[]; + + /** + * Custom merge handlers for specific keys. + * The handler receives the accumulated target and current source, + * and returns the new value for that key. + */ + specialHandling?: Record, source: Record) => unknown>; + } = {}, +): T { const { fullOverrideProps = [], specialHandling = {} } = options; if (!propertiesArray || propertiesArray.length === 0) { - return {}; + return {} as T; } /** * Deep merges two objects while respecting override lists and per-key handlers. */ - const merge = (target: PropertyObject, source: PropertyObject): PropertyObject => { - const output: PropertyObject = { ...target }; + const merge = (target: Record, source: Record): PropertyObject => { + const output: Record = { ...target }; if (isObject(target) && isObject(source)) { for (const key in source) { @@ -140,7 +86,7 @@ export function combineProperties( } else if (!fullOverrideProps.includes(key) && isObject(source[key])) { // Deep merge nested objects (unless marked for full override) if (key in target && isObject(target[key])) { - output[key] = merge(target[key] as PropertyObject, source[key] as PropertyObject); + output[key] = merge(target[key] as Record, source[key] as Record); } else { output[key] = source[key]; } @@ -155,97 +101,16 @@ export function combineProperties( return output; }; - return propertiesArray.reduce((acc, current) => merge(acc, current ?? {}), {}); -} - -/** - * Combines run property objects while fully overriding certain keys. - * This is a convenience wrapper for run properties (w:rPr). - * - * @param propertiesArray - Ordered list of run property objects. - * @returns Combined run property object. - */ -export function combineRunProperties(propertiesArray: PropertyObject[]): PropertyObject { - return combineProperties(propertiesArray, { - fullOverrideProps: ['fontFamily', 'color'], - }); + return propertiesArray.reduce((acc, current) => merge(acc, (current ?? {}) as Record), {}) as T; } /** - * Applies inline override properties to ensure direct formatting always wins. - * - * Even though inline properties come last in the style chain, we explicitly - * override to guarantee correctness. This is critical for properties like - * fontSize where inline w:sz must override w:rStyle fontSize. - * - * @param finalProps - The merged properties from the style chain. - * @param inlineProps - The inline (direct) formatting properties. - * @param overrideKeys - Which keys to force override (defaults to INLINE_OVERRIDE_PROPERTIES). - * @returns The finalProps object with inline overrides applied. - */ -export function applyInlineOverrides( - finalProps: PropertyObject, - inlineProps: PropertyObject | null | undefined, - overrideKeys: readonly string[] = INLINE_OVERRIDE_PROPERTIES, -): PropertyObject { - if (!inlineProps) return finalProps; - - for (const prop of overrideKeys) { - if (inlineProps[prop] != null) { - finalProps[prop] = inlineProps[prop]; - } - } - - return finalProps; -} - -// --------------------------------------------------------------------------- -// Font Size Fallback -// --------------------------------------------------------------------------- - -/** - * Validates that a font size value is valid (positive finite number). - */ -export function isValidFontSize(value: unknown): value is number { - return typeof value === 'number' && Number.isFinite(value) && value > 0; -} - -/** - * Resolves font size with fallback cascade. - * - * Falls back through multiple sources to ensure all text has a valid font size: - * 1. The provided value (if valid) - * 2. Document defaults - * 3. Normal style - * 4. Baseline constant (DEFAULT_FONT_SIZE_HALF_POINTS = 20 = 10pt) - * - * @param value - The resolved font size from the style chain. - * @param defaultProps - Document default properties. - * @param normalProps - Normal style properties. - * @returns A valid positive font size in half-points. + * Determines whether the supplied value is a mergeable plain object. + * @param item - Value to inspect. + * @returns True when the value is a non-array object. */ -export function resolveFontSizeWithFallback( - value: unknown, - defaultProps?: PropertyObject | null, - normalProps?: PropertyObject | null, -): number { - // If the value is already valid, use it - if (isValidFontSize(value)) { - return value; - } - - // Try document defaults - if (defaultProps && isValidFontSize(defaultProps.fontSize)) { - return defaultProps.fontSize; - } - - // Try Normal style - if (normalProps && isValidFontSize(normalProps.fontSize)) { - return normalProps.fontSize; - } - - // Final fallback: 20 half-points = 10pt - return DEFAULT_FONT_SIZE_HALF_POINTS; +function isObject(item: unknown): item is PropertyObject { + return item != null && typeof item === 'object' && !Array.isArray(item); } // --------------------------------------------------------------------------- @@ -264,11 +129,11 @@ export function resolveFontSizeWithFallback( * @param isNormalDefault - Whether Normal style has w:default="1". * @returns Ordered array [first, second] for the cascade. */ -export function orderDefaultsAndNormal( - defaultProps: PropertyObject, - normalProps: PropertyObject, +export function orderDefaultsAndNormal( + defaultProps: T, + normalProps: T, isNormalDefault: boolean, -): [PropertyObject, PropertyObject] { +): [T, T] { if (isNormalDefault) { // Normal is default: [defaults, Normal] - Normal wins when both exist return [defaultProps, normalProps]; @@ -277,60 +142,62 @@ export function orderDefaultsAndNormal( return [normalProps, defaultProps]; } } - -// --------------------------------------------------------------------------- -// Indent Special Handling -// --------------------------------------------------------------------------- - -/** - * Creates a special handler for firstLine indent that removes hanging when firstLine is set. - * - * Per OOXML, when a higher priority source defines firstLine, it should - * remove hanging from the final result (they are mutually exclusive). - */ -export function createFirstLineIndentHandler(): SpecialHandler { - return (target: PropertyObject, source: PropertyObject): unknown => { - // If a higher priority source defines firstLine, remove hanging from the final result - if (target.hanging != null && source.firstLine != null) { - delete target.hanging; - } - return source.firstLine; - }; -} - /** - * Creates a special handler for hanging indent that removes firstLine when hanging is set. - * - * Per OOXML, when a higher priority source defines hanging, it should - * remove firstLine from the final result (they are mutually exclusive). + * Combines run property objects while fully overriding certain keys. + * This is a convenience wrapper for run properties (w:rPr). * - * @returns A SpecialHandler function that processes hanging indent values and - * removes conflicting firstLine values from the target object + * @param propertiesArray - Ordered list of run property objects. + * @returns Combined run property object. */ -export function createHangingIndentHandler(): SpecialHandler { - return (target: PropertyObject, source: PropertyObject): unknown => { - // If a higher priority source defines hanging, remove firstLine from the final result - if (target.firstLine != null && source.hanging != null) { - delete target.firstLine; - } - return source.hanging; - }; +export function combineRunProperties(propertiesArray: RunProperties[]): RunProperties { + return combineProperties(propertiesArray, { + fullOverrideProps: ['color'], + specialHandling: { + fontFamily: (target: Record, source: Record): unknown => { + const fontFamilySource = { ...(source.fontFamily as object) } as RunFontFamilyProperties; + const fontFamilyTarget = { ...(target.fontFamily as object) } as RunFontFamilyProperties; + if (fontFamilySource.asciiTheme != null) { + delete fontFamilyTarget.ascii; + delete fontFamilyTarget.asciiTheme; + } + if (fontFamilySource.ascii != null) { + delete fontFamilyTarget.asciiTheme; + } + return { ...(fontFamilyTarget as object), ...(fontFamilySource as object) }; + }, + }, + }); } +// --------------------------------------------------------------------------- +// Indent Special Handling +// --------------------------------------------------------------------------- /** * Combines indent properties with special handling for firstLine/hanging mutual exclusivity. * * @param indentChain - Ordered list of indent property objects (or objects with indent property). * @returns Combined indent object. */ -export function combineIndentProperties(indentChain: PropertyObject[]): PropertyObject { +export function combineIndentProperties(indentChain: ParagraphProperties[]): ParagraphProperties { // Extract just the indent properties from each object const indentOnly = indentChain.map((props) => (props.indent != null ? { indent: props.indent } : {})); return combineProperties(indentOnly, { specialHandling: { - firstLine: createFirstLineIndentHandler(), - hanging: createHangingIndentHandler(), + firstLine: (target: Record, source: Record): unknown => { + // If a higher priority source defines firstLine, remove hanging from the final result + if (target.hanging != null && source.firstLine != null) { + delete target.hanging; + } + return source.firstLine; + }, + hanging: (target: Record, source: Record): unknown => { + // If a higher priority source defines hanging, remove firstLine from the final result + if (target.firstLine != null && source.hanging != null) { + delete target.firstLine; + } + return source.hanging; + }, }, }); } diff --git a/packages/layout-engine/style-engine/src/index.test.ts b/packages/layout-engine/style-engine/src/index.test.ts index a7c09f842d..f21034729c 100644 --- a/packages/layout-engine/style-engine/src/index.test.ts +++ b/packages/layout-engine/style-engine/src/index.test.ts @@ -1,457 +1,5 @@ import { describe, expect, it, beforeEach } from 'vitest'; -import { - resolveNumbering, - resolveStyle, - resolveTableCellStyle, - StyleContext, - StyleNode, - resolveSdtMetadata, - clearSdtMetadataCache, -} from './index.js'; - -const baseContext: StyleContext = { - defaults: { - paragraphFont: 'Calibri', - fontSize: 11, - }, -}; - -describe('style-engine resolveStyle', () => { - it('returns defaults when no style info is provided', () => { - const result = resolveStyle({}, baseContext); - expect(result.paragraph.spacing).toEqual({ - before: 0, - after: 0, - line: 12, - lineRule: 'auto', - }); - expect(result.character.font?.family).toBe('Calibri, sans-serif'); - expect(result.paragraph.tabs).toEqual([]); - }); - - it('applies style chain inheritance', () => { - const context: StyleContext = { - ...baseContext, - styles: { - Normal: { - id: 'Normal', - paragraph: { spacing: { before: 6 } }, - }, - Heading1: { - id: 'Heading1', - basedOn: 'Normal', - paragraph: { spacing: { before: 12 }, indent: { left: 18 } }, - }, - }, - }; - - const node: StyleNode = { styleId: 'Heading1' }; - const result = resolveStyle(node, context); - expect(result.paragraph.spacing?.before).toBe(12); - expect(result.paragraph.indent?.left).toBe(18); - }); - - it('applies fallback font families from defaults', () => { - const context: StyleContext = { - defaults: { - paragraphFont: 'Cambria', - paragraphFontFallback: 'Georgia, serif', - }, - }; - const result = resolveStyle({}, context); - expect(result.character.font?.family).toBe('Cambria, Georgia, serif'); - }); - - it('handles basedOn cycles without infinite loops', () => { - const context: StyleContext = { - ...baseContext, - styles: { - Normal: { - id: 'Normal', - basedOn: 'Heading1', - paragraph: { indent: { left: 24 } }, - }, - Heading1: { - id: 'Heading1', - basedOn: 'Normal', - paragraph: { spacing: { before: 8 } }, - }, - }, - }; - - const result = resolveStyle({ styleId: 'Heading1' }, context); - expect(result.paragraph.indent?.left).toBe(24); - expect(result.paragraph.spacing?.before).toBe(8); - }); - - it('prefers node numbering overrides over style chain numbering', () => { - const context: StyleContext = { - ...baseContext, - styles: { - ListParagraph: { - id: 'ListParagraph', - numbering: { numId: 'num1', level: 0 }, - }, - }, - numbering: { - num1: { - levels: [ - { - level: 0, - format: 'decimal', - indent: { left: 36, hanging: 18 }, - }, - ], - }, - num2: { - levels: [ - { - level: 0, - format: 'upperLetter', - indent: { left: 48, hanging: 24 }, - }, - ], - }, - }, - }; - - const node: StyleNode = { - styleId: 'ListParagraph', - numbering: { numId: 'num2', level: 0 }, - }; - const result = resolveStyle(node, context); - expect(result.numbering?.numId).toBe('num2'); - expect(result.numbering?.format).toBe('upperLetter'); - }); - - it('overrides style tabs when direct formatting supplies explicit tabs', () => { - const context: StyleContext = { - ...baseContext, - styles: { - Tabbable: { - id: 'Tabbable', - paragraph: { - tabs: [{ pos: 36, align: 'left' }], - }, - }, - }, - }; - - const node: StyleNode = { - styleId: 'Tabbable', - paragraphProps: { tabs: [] }, - }; - const result = resolveStyle(node, context); - expect(result.paragraph.tabs).toEqual([]); - }); - - it('merges character formatting between styles and direct props', () => { - const context: StyleContext = { - ...baseContext, - styles: { - Emphasis: { - id: 'Emphasis', - character: { font: { weight: 700 }, color: '#222222' }, - }, - }, - }; - - const node: StyleNode = { - styleId: 'Emphasis', - characterProps: { - font: { italic: true }, - underline: { style: 'dotted' }, - }, - }; - const result = resolveStyle(node, context); - expect(result.character.font?.weight).toBe(700); - expect(result.character.font?.italic).toBe(true); - expect(result.character.underline?.style).toBe('dotted'); - expect(result.character.color).toBe('#222222'); - }); - - it('merges shading values from style chain and direct formatting', () => { - const context: StyleContext = { - ...baseContext, - styles: { - Shaded: { - id: 'Shaded', - paragraph: { shading: { fill: '#eeeeee' } }, - }, - }, - }; - - const node: StyleNode = { - styleId: 'Shaded', - paragraphProps: { shading: { pattern: 'clear' } }, - }; - - const result = resolveStyle(node, context); - expect(result.paragraph.shading).toEqual({ pattern: 'clear' }); - }); - - it('merges paragraph borders without clobbering unspecified sides', () => { - const context: StyleContext = { - ...baseContext, - styles: { - Bordered: { - id: 'Bordered', - paragraph: { - borders: { - top: { style: 'solid', width: 1 }, - }, - }, - }, - }, - }; - - const node: StyleNode = { - styleId: 'Bordered', - paragraphProps: { - borders: { - bottom: { style: 'dotted', width: 2 }, - }, - }, - }; - - const result = resolveStyle(node, context); - expect(result.paragraph.borders?.top).toEqual({ style: 'solid', width: 1 }); - expect(result.paragraph.borders?.bottom).toEqual({ style: 'dotted', width: 2 }); - }); - - it('applies character overrides even when no style definitions exist', () => { - const node: StyleNode = { - characterProps: { font: { weight: 600 }, color: '#123456' }, - }; - const result = resolveStyle(node, baseContext); - expect(result.character.font?.weight).toBe(600); - expect(result.character.color).toBe('#123456'); - }); - - it('leaves numbering undefined when referenced definition is missing', () => { - const context: StyleContext = { - ...baseContext, - styles: { - ListParagraph: { - id: 'ListParagraph', - numbering: { numId: 'missing', level: 0 }, - }, - }, - }; - - const result = resolveStyle({ styleId: 'ListParagraph' }, context); - expect(result.numbering).toBeUndefined(); - }); - - it('applies numbering indent from style chain when definition exists', () => { - const context: StyleContext = { - ...baseContext, - styles: { - ListParagraph: { - id: 'ListParagraph', - numbering: { numId: 'num1', level: 0 }, - }, - }, - numbering: { - num1: { - levels: [ - { - level: 0, - indent: { left: 54, hanging: 18 }, - }, - ], - }, - }, - }; - - const result = resolveStyle({ styleId: 'ListParagraph' }, context); - expect(result.numbering?.indent).toEqual({ left: 54, hanging: 18 }); - }); - - it('merges direct formatting over styles', () => { - const context: StyleContext = { - ...baseContext, - styles: { - Normal: { - id: 'Normal', - paragraph: { indent: { left: 12 } }, - }, - }, - }; - - const node: StyleNode = { - styleId: 'Normal', - paragraphProps: { indent: { left: 24, right: 6 } }, - }; - const result = resolveStyle(node, context); - expect(result.paragraph.indent?.left).toBe(24); - expect(result.paragraph.indent?.right).toBe(6); - }); - - it('resolves numbering from style chain and node overrides', () => { - const context: StyleContext = { - ...baseContext, - styles: { - ListParagraph: { - id: 'ListParagraph', - numbering: { numId: 'num1', level: 0 }, - }, - }, - numbering: { - num1: { - levels: [ - { - level: 0, - format: 'decimal', - text: '%1.', - start: 1, - indent: { left: 36, hanging: 18 }, - }, - ], - }, - }, - }; - - const node: StyleNode = { styleId: 'ListParagraph' }; - const styleResult = resolveStyle(node, context); - expect(styleResult.numbering).toEqual({ - numId: 'num1', - level: 0, - indent: { left: 36, hanging: 18 }, - format: 'decimal', - text: '%1.', - start: 1, - }); - - const overrideNode: StyleNode = { - numbering: { numId: 'num1', level: 0 }, - }; - const overrideResult = resolveStyle(overrideNode, context); - expect(overrideResult.numbering?.numId).toBe('num1'); - }); - - it('attaches SDT metadata when provided via options', () => { - const result = resolveStyle({}, baseContext, { - sdt: { - nodeType: 'documentSection', - attrs: { id: '10', title: 'Intro', description: 'Overview', isLocked: true }, - }, - }); - - expect(result.sdt).toEqual({ - type: 'documentSection', - id: '10', - title: 'Intro', - description: 'Overview', - sectionType: null, - isLocked: true, - sdBlockId: null, - }); - }); -}); - -describe('style-engine resolveNumbering', () => { - it('returns undefined when numbering definition missing', () => { - const result = resolveNumbering('missing', 0, baseContext); - expect(result).toBeUndefined(); - }); - - it('returns level definition when available', () => { - const context: StyleContext = { - numbering: { - num2: { - levels: [ - { - level: 0, - format: 'upperLetter', - text: '%1)', - start: 5, - indent: { left: 40, hanging: 20 }, - }, - ], - }, - }, - }; - - const result = resolveNumbering('num2', 0, context); - expect(result).toEqual({ - numId: 'num2', - level: 0, - indent: { left: 40, hanging: 20 }, - format: 'upperLetter', - text: '%1)', - start: 5, - }); - }); - - it('falls back to positional level when explicit level entry is missing', () => { - const context: StyleContext = { - numbering: { - num3: { - levels: [ - { - level: 0, - format: 'decimal', - indent: { left: 32, hanging: 16 }, - }, - { - level: 2, - format: 'lowerLetter', - indent: { left: 48, hanging: 24 }, - }, - ], - }, - }, - }; - - const result = resolveNumbering('num3', 1, context); - expect(result).toEqual({ - numId: 'num3', - level: 1, - indent: { left: 48, hanging: 24 }, - format: 'lowerLetter', - text: '%1.', - start: 1, - }); - }); - - it('defaults text and start values when level omits them', () => { - const context: StyleContext = { - numbering: { - num4: { - levels: [ - { - level: 0, - format: 'decimal', - }, - ], - }, - }, - }; - - const result = resolveNumbering('num4', 0, context); - expect(result?.text).toBe('%1.'); - expect(result?.start).toBe(1); - }); - - it('returns undefined when level index exceeds available definitions', () => { - const context: StyleContext = { - numbering: { - num5: { - levels: [ - { - level: 0, - format: 'decimal', - }, - ], - }, - }, - }; - - const result = resolveNumbering('num5', 5, context); - expect(result).toBeUndefined(); - }); -}); +import { resolveSdtMetadata, clearSdtMetadataCache } from './index.js'; describe('resolveSdtMetadata', () => { beforeEach(() => { @@ -638,7 +186,7 @@ describe('resolveSdtMetadata', () => { nodeType: 'docPartObject', attrs: { docPartGallery: 'Table of Contents', - id: 'toc-unique-1', // Changed from docPartUnique to id + id: 'toc-unique-1', alias: 'TOC', instruction: 'TOC \\o "1-3"', }, @@ -652,17 +200,3 @@ describe('resolveSdtMetadata', () => { }); }); }); -describe('style-engine resolveTableCellStyle', () => { - it('reuses resolveStyle defaults for table cells for now', () => { - const tableContext: StyleContext = { - defaults: { - paragraphFont: 'Arial', - fontSize: 10, - }, - }; - - const cellStyle = resolveTableCellStyle({}, 0, 0, tableContext); - expect(cellStyle.paragraph.alignment).toBe('left'); - expect(cellStyle.character.font?.family).toBe('Arial, sans-serif'); - }); -}); diff --git a/packages/layout-engine/style-engine/src/index.ts b/packages/layout-engine/style-engine/src/index.ts index e7d07268a2..2cd2c462b1 100644 --- a/packages/layout-engine/style-engine/src/index.ts +++ b/packages/layout-engine/style-engine/src/index.ts @@ -10,24 +10,13 @@ * - Conversion to pixels happens at measurement boundary only */ -import { toCssFontFamily } from '@superdoc/font-utils'; - // Re-export cascade utilities - these are the SINGLE SOURCE OF TRUTH for property merging export { combineProperties, combineRunProperties, - applyInlineOverrides, - resolveFontSizeWithFallback, orderDefaultsAndNormal, combineIndentProperties, - createFirstLineIndentHandler, - createHangingIndentHandler, - isValidFontSize, - INLINE_OVERRIDE_PROPERTIES, - DEFAULT_FONT_SIZE_HALF_POINTS, type PropertyObject, - type SpecialHandler, - type CombinePropertiesOptions, } from './cascade.js'; import type { TabStop, @@ -104,81 +93,9 @@ export interface ComputedParagraphStyle { tabs?: TabStop[]; } -export interface ComputedCharacterStyle { - font?: { - family: string; - size?: number; - weight?: number; - italic?: boolean; - }; - color?: string; - underline?: { - style?: 'single' | 'double' | 'dotted' | 'dashed' | 'wavy'; - color?: string; - }; - strike?: boolean; - highlight?: string; - letterSpacing?: number; -} - -export interface NumberingStyle { - numId: string; - level: number; - indent?: { - left?: number; - hanging?: number; - }; - format?: 'decimal' | 'lowerLetter' | 'upperLetter' | 'lowerRoman' | 'upperRoman' | 'bullet' | 'custom'; - text?: string; - start?: number; -} - -export interface ComputedStyle { - paragraph: ComputedParagraphStyle; - character: ComputedCharacterStyle; - numbering?: NumberingStyle; - sdt?: SdtMetadata; -} - -export interface StyleNode { - styleId?: string; - paragraphProps?: Partial; - characterProps?: Partial; - numbering?: { - numId: string; - level: number; - }; -} - -export interface ParagraphStyleDefinition { - id: string; - basedOn?: string; - paragraph?: Partial; - character?: Partial; - numbering?: { - numId: string; - level: number; - }; -} - -export interface NumberingLevelDefinition { - level: number; - format?: NumberingStyle['format']; - text?: string; - start?: number; - indent?: { - left?: number; - hanging?: number; - }; -} - -export interface NumberingDefinition { - levels: NumberingLevelDefinition[]; -} - export interface StyleContext { - styles?: Record; - numbering?: Record; + styles?: Record; + numbering?: Record; theme?: Record; defaults?: { paragraphFont?: string; @@ -216,137 +133,6 @@ export function clearSdtMetadataCache(): void { sdtMetadataCache.clear(); } -/** - * Resolves a node's fully-computed style by applying OOXML cascade rules. - * - * Cascade order: - * 1. Document defaults - * 2. Style chain (basedOn hierarchy) - * 3. Direct paragraph/character formatting - * 4. Numbering overrides - * 5. SDT metadata (if provided via options) - * - * @param node - The style node containing styleId and direct formatting - * @param context - Style definitions, numbering, theme, and defaults - * @param options - Optional SDT metadata to attach to the computed style - * @returns Fully-resolved ComputedStyle with paragraph, character, numbering, and optional SDT metadata - * - * @example - * ```typescript - * import { resolveStyle } from '@superdoc/style-engine'; - * - * const style = resolveStyle( - * { styleId: 'Heading1', paragraphProps: { indent: { left: 36 } } }, - * { styles: {...}, defaults: { paragraphFont: 'Calibri', fontSize: 11 } } - * ); - * - * console.log(style.paragraph.indent.left); // 36 - * console.log(style.character.font.family); // 'Calibri, sans-serif' - * ``` - */ -export function resolveStyle(node: StyleNode, context: StyleContext, options: ResolveStyleOptions = {}): ComputedStyle { - let paragraph = createDefaultParagraph(context); - let character = createDefaultCharacter(context); - let numbering: NumberingStyle | undefined; - - const chain = resolveStyleChain(node.styleId, context.styles); - - for (const style of chain) { - paragraph = mergeParagraph(paragraph, style.paragraph); - character = mergeCharacter(character, style.character); - if (!numbering && style.numbering) { - numbering = resolveNumbering(style.numbering.numId, style.numbering.level, context); - } - } - - paragraph = mergeParagraph(paragraph, node.paragraphProps); - character = mergeCharacter(character, node.characterProps); - - if (node.numbering) { - numbering = resolveNumbering(node.numbering.numId, node.numbering.level, context); - } - - const sdt = options?.sdt ? resolveSdtMetadata(options.sdt) : undefined; - - return { - paragraph, - character, - numbering, - sdt, - }; -} - -/** - * Resolves numbering metadata for a list item at a specific level. - * - * Looks up the numbering definition by `numId` and extracts the level-specific - * formatting (format, text, indent, start value). Returns undefined if the - * definition or level is not found. - * - * @param numId - The numbering definition ID (from w:numPr/w:numId) - * @param level - The zero-based level index (from w:numPr/w:ilvl) - * @param context - Style context containing numbering definitions - * @returns Resolved NumberingStyle or undefined if not found - * - * @example - * ```typescript - * import { resolveNumbering } from '@superdoc/style-engine'; - * - * const numbering = resolveNumbering('1', 0, { - * numbering: { - * '1': { - * levels: [{ level: 0, format: 'decimal', text: '%1.', indent: { left: 36, hanging: 18 } }] - * } - * } - * }); - * - * console.log(numbering?.format); // 'decimal' - * console.log(numbering?.text); // '%1.' - * ``` - */ -export function resolveNumbering(numId: string, level: number, context: StyleContext): NumberingStyle | undefined { - const def = context.numbering?.[numId]; - if (!def) return undefined; - - const levelDef = def.levels.find((entry) => entry.level === level) ?? def.levels[level]; - - if (!levelDef) return undefined; - - return { - numId, - level, - indent: { - left: levelDef.indent?.left, - hanging: levelDef.indent?.hanging, - }, - format: levelDef.format ?? 'decimal', - text: levelDef.text ?? '%1.', - start: levelDef.start ?? 1, - }; -} - -/** - * Resolves style for a table cell's content. - * - * Note: This is a placeholder implementation that returns document defaults. - * Full table cascade (tblPr -> trPr -> tcPr -> pPr) will be implemented in a future phase. - * - * @param table - Table element (reserved for future use) - * @param row - Row index (reserved for future use) - * @param col - Column index (reserved for future use) - * @param context - Style context containing defaults - * @returns ComputedStyle with document defaults - */ -export function resolveTableCellStyle( - _table: unknown, - _row: number, - _col: number, - context: StyleContext, -): ComputedStyle { - // Placeholder: table cascade arrives with tables phase. For now, reuse resolveStyle defaults. - return resolveStyle({}, context); -} - /** * Normalizes Structured Document Tag (SDT) metadata into a stable contract shape. * @@ -419,94 +205,6 @@ export function resolveSdtMetadata(input?: ResolveSdtMetadataInput | null): SdtM // Helpers // --------------------------------------------------------------------------- -function createDefaultParagraph(_context: StyleContext): ComputedParagraphStyle { - return { - alignment: 'left', - spacing: { - before: 0, - after: 0, - line: 12, - lineRule: 'auto', - }, - indent: { - left: 0, - right: 0, - firstLine: 0, - hanging: 0, - }, - tabs: [], - }; -} - -function createDefaultCharacter(context: StyleContext): ComputedCharacterStyle { - const baseFont = context.defaults?.paragraphFont ?? 'Calibri'; - const fallback = context.defaults?.paragraphFontFallback; - const wordFamily = context.defaults?.paragraphFontFamily; - const resolvedFamily = toCssFontFamily(baseFont, { fallback, wordFamily }) ?? baseFont; - - return { - font: { - family: resolvedFamily, - size: context.defaults?.fontSize ?? 11, - weight: 400, - italic: false, - }, - color: '#000000', - }; -} - -function resolveStyleChain( - styleId: string | undefined, - styles?: Record, -): ParagraphStyleDefinition[] { - if (!styleId || !styles) return []; - const result: ParagraphStyleDefinition[] = []; - const visited = new Set(); - let current: ParagraphStyleDefinition | undefined = styles[styleId]; - - while (current && !visited.has(current.id)) { - result.unshift(current); - visited.add(current.id); - current = current.basedOn ? styles[current.basedOn] : undefined; - } - - return result; -} - -function mergeParagraph( - base: ComputedParagraphStyle, - overrides?: Partial, -): ComputedParagraphStyle { - if (!overrides) return base; - - return { - ...base, - alignment: overrides.alignment ?? base.alignment, - spacing: overrides.spacing ? { ...base.spacing, ...overrides.spacing } : base.spacing, - indent: overrides.indent ? { ...base.indent, ...overrides.indent } : base.indent, - borders: overrides.borders ? { ...base.borders, ...overrides.borders } : base.borders, - shading: overrides.shading ?? base.shading, - tabs: overrides.tabs ?? base.tabs, - }; -} - -function mergeCharacter( - base: ComputedCharacterStyle, - overrides?: Partial, -): ComputedCharacterStyle { - if (!overrides) return base; - - return { - ...base, - font: overrides.font ? { ...base.font, ...overrides.font } : base.font, - color: overrides.color ?? base.color, - underline: overrides.underline ?? base.underline, - strike: overrides.strike ?? base.strike, - highlight: overrides.highlight ?? base.highlight, - letterSpacing: overrides.letterSpacing ?? base.letterSpacing, - }; -} - function normalizeFieldAnnotationMetadata(attrs: Record): FieldAnnotationMetadata { const fieldId = toOptionalString(attrs.fieldId) ?? ''; const formatting = extractFormatting(attrs); diff --git a/packages/layout-engine/style-engine/src/ooxml/index.test.ts b/packages/layout-engine/style-engine/src/ooxml/index.test.ts index 6beb756441..026541b8ce 100644 --- a/packages/layout-engine/style-engine/src/ooxml/index.test.ts +++ b/packages/layout-engine/style-engine/src/ooxml/index.test.ts @@ -1,1165 +1,376 @@ -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { - createOoxmlResolver, resolveStyleChain, - getDefaultProperties, - getStyleProperties, getNumberingProperties, resolveDocxFontFamily, resolveRunProperties, resolveParagraphProperties, - type OoxmlTranslator, + resolveCellStyles, type OoxmlResolverParams, } from './index.js'; -// Mock translators for testing -const mockPPrTranslator: OoxmlTranslator = { - xmlName: 'w:pPr', - encode: vi.fn((params) => { - const nodes = (params as { nodes?: Array> })?.nodes; - if (!nodes || nodes.length === 0) return {}; - // Simple mock implementation that extracts test data - return (nodes[0]?.mockData as Record) || {}; - }), -}; +const emptyStyles = { docDefaults: {}, latentStyles: {}, styles: {} }; +const emptyNumbering = { abstracts: {}, definitions: {} }; -const mockRPrTranslator: OoxmlTranslator = { - xmlName: 'w:rPr', - encode: vi.fn((params) => { - const nodes = (params as { nodes?: Array> })?.nodes; - if (!nodes || nodes.length === 0) return {}; - return (nodes[0]?.mockData as Record) || {}; - }), -}; - -describe('ooxml - createOoxmlResolver', () => { - it('returns an object with all expected methods', () => { - const resolver = createOoxmlResolver({ pPr: mockPPrTranslator, rPr: mockRPrTranslator }); - expect(resolver).toHaveProperty('resolveRunProperties'); - expect(resolver).toHaveProperty('resolveParagraphProperties'); - expect(resolver).toHaveProperty('getDefaultProperties'); - expect(resolver).toHaveProperty('getStyleProperties'); - expect(resolver).toHaveProperty('resolveStyleChain'); - expect(resolver).toHaveProperty('getNumberingProperties'); - expect(typeof resolver.resolveRunProperties).toBe('function'); - expect(typeof resolver.resolveParagraphProperties).toBe('function'); - expect(typeof resolver.getDefaultProperties).toBe('function'); - expect(typeof resolver.getStyleProperties).toBe('function'); - expect(typeof resolver.resolveStyleChain).toBe('function'); - expect(typeof resolver.getNumberingProperties).toBe('function'); - }); - - it('creates a resolver with bound methods', () => { - const resolver = createOoxmlResolver({ pPr: mockPPrTranslator, rPr: mockRPrTranslator }); - // Methods should be callable without context - expect(() => resolver.getDefaultProperties).not.toThrow(); - }); +const buildParams = (overrides?: Partial): OoxmlResolverParams => ({ + translatedLinkedStyles: emptyStyles, + translatedNumbering: emptyNumbering, + ...overrides, }); describe('ooxml - resolveStyleChain', () => { it('returns empty object when styleId is undefined', () => { - const params: OoxmlResolverParams = { docx: {} }; - const result = resolveStyleChain(params, undefined, mockRPrTranslator); - expect(result).toEqual({}); - }); - - it('returns empty object when styleId is "Normal"', () => { - const params: OoxmlResolverParams = { docx: {} }; - const result = resolveStyleChain(params, 'Normal', mockRPrTranslator); + const params = buildParams(); + const result = resolveStyleChain('runProperties', params, undefined); expect(result).toEqual({}); }); it('resolves a single style without basedOn', () => { - const params: OoxmlResolverParams = { - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:style', - attributes: { 'w:styleId': 'Heading1' }, - elements: [ - { - name: 'w:rPr', - mockData: { fontSize: 32, bold: true }, - }, - ], - }, - ], - }, - ], + const params = buildParams({ + translatedLinkedStyles: { + ...emptyStyles, + styles: { + Heading1: { runProperties: { fontSize: 32, bold: true } }, }, }, - }; - const result = resolveStyleChain(params, 'Heading1', mockRPrTranslator); + }); + const result = resolveStyleChain('runProperties', params, 'Heading1'); expect(result).toEqual({ fontSize: 32, bold: true }); }); it('follows basedOn chain and combines properties', () => { - const params: OoxmlResolverParams = { - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:style', - attributes: { 'w:styleId': 'BaseStyle' }, - elements: [ - { - name: 'w:rPr', - mockData: { fontSize: 22, italic: true }, - }, - ], - }, - { - name: 'w:style', - attributes: { 'w:styleId': 'DerivedStyle' }, - elements: [ - { - name: 'w:basedOn', - attributes: { 'w:val': 'BaseStyle' }, - }, - { - name: 'w:rPr', - mockData: { fontSize: 24, bold: true }, - }, - ], - }, - ], - }, - ], + const params = buildParams({ + translatedLinkedStyles: { + ...emptyStyles, + styles: { + BaseStyle: { runProperties: { fontSize: 22, italic: true } }, + DerivedStyle: { basedOn: 'BaseStyle', runProperties: { fontSize: 24, bold: true } }, }, }, - }; - const result = resolveStyleChain(params, 'DerivedStyle', mockRPrTranslator); + }); + const result = resolveStyleChain('runProperties', params, 'DerivedStyle'); expect(result).toEqual({ fontSize: 24, bold: true, italic: true }); }); - it('detects and breaks cycles in basedOn chain', () => { - const params: OoxmlResolverParams = { - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:style', - attributes: { 'w:styleId': 'StyleA' }, - elements: [ - { - name: 'w:basedOn', - attributes: { 'w:val': 'StyleB' }, - }, - { - name: 'w:rPr', - mockData: { fontSize: 22 }, - }, - ], - }, - { - name: 'w:style', - attributes: { 'w:styleId': 'StyleB' }, - elements: [ - { - name: 'w:basedOn', - attributes: { 'w:val': 'StyleA' }, - }, - { - name: 'w:rPr', - mockData: { bold: true }, - }, - ], - }, - ], - }, - ], - }, - }, - }; - // Should not infinite loop - const result = resolveStyleChain(params, 'StyleA', mockRPrTranslator); - expect(result).toHaveProperty('fontSize'); - expect(result).toHaveProperty('bold'); - }); - - it('returns empty object when followBasedOnChain is false', () => { - const params: OoxmlResolverParams = { - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:style', - attributes: { 'w:styleId': 'DerivedStyle' }, - elements: [ - { - name: 'w:basedOn', - attributes: { 'w:val': 'BaseStyle' }, - }, - { - name: 'w:rPr', - mockData: { fontSize: 24 }, - }, - ], - }, - ], - }, - ], - }, - }, - }; - const result = resolveStyleChain(params, 'DerivedStyle', mockRPrTranslator, false); - expect(result).toEqual({ fontSize: 24 }); // Only direct style, no basedOn - }); - - it('handles missing style definitions gracefully', () => { - const params: OoxmlResolverParams = { - docx: { - 'word/styles.xml': { - elements: [{ elements: [] }], - }, - }, - }; - const result = resolveStyleChain(params, 'MissingStyle', mockRPrTranslator); - expect(result).toEqual({}); - }); - - it('handles empty docx', () => { - const params: OoxmlResolverParams = { docx: {} }; - const result = resolveStyleChain(params, 'AnyStyle', mockRPrTranslator); + it('returns empty object when styleId is missing from definitions', () => { + const params = buildParams(); + const result = resolveStyleChain('runProperties', params, 'MissingStyle'); expect(result).toEqual({}); }); +}); - it('combines multiple levels in basedOn chain correctly', () => { - const params: OoxmlResolverParams = { - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:style', - attributes: { 'w:styleId': 'Level1' }, - elements: [ - { - name: 'w:rPr', - mockData: { fontSize: 20, bold: true, color: 'red' }, - }, - ], - }, - { - name: 'w:style', - attributes: { 'w:styleId': 'Level2' }, - elements: [ - { - name: 'w:basedOn', - attributes: { 'w:val': 'Level1' }, - }, - { - name: 'w:rPr', - mockData: { fontSize: 22, italic: true }, - }, - ], - }, - { - name: 'w:style', - attributes: { 'w:styleId': 'Level3' }, - elements: [ - { - name: 'w:basedOn', - attributes: { 'w:val': 'Level2' }, - }, - { - name: 'w:rPr', - mockData: { fontSize: 24, strike: true }, - }, - ], - }, - ], +describe('ooxml - getNumberingProperties', () => { + it('extracts properties from abstractNum level definition', () => { + const params = buildParams({ + translatedNumbering: { + definitions: { + '1': { abstractNumId: 10 }, + }, + abstracts: { + '10': { + levels: { + '0': { paragraphProperties: { spacing: { before: 240 } } }, }, - ], + }, }, }, - }; - const result = resolveStyleChain(params, 'Level3', mockRPrTranslator); - expect(result).toEqual({ - fontSize: 24, // From Level3 (highest priority) - bold: true, // From Level1 - italic: true, // From Level2 - strike: true, // From Level3 - color: 'red', // From Level1 }); + const result = getNumberingProperties('paragraphProperties', params, 0, 1); + expect(result).toEqual({ spacing: { before: 240 } }); }); -}); -describe('ooxml - getDefaultProperties', () => { - it('extracts default properties from w:docDefaults', () => { - const params: OoxmlResolverParams = { - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:docDefaults', - elements: [ - { - name: 'w:rPrDefault', - elements: [ - { - name: 'w:rPr', - mockData: { fontSize: 22, fontFamily: { ascii: 'Calibri' } }, - }, - ], - }, - ], - }, - ], + it('applies lvlOverride over abstractNum properties', () => { + const params = buildParams({ + translatedNumbering: { + definitions: { + '1': { + abstractNumId: 10, + lvlOverrides: { + '0': { paragraphProperties: { spacing: { after: 120 } } }, }, - ], + }, }, - }, - }; - const result = getDefaultProperties(params, mockRPrTranslator); - expect(result).toEqual({ fontSize: 22, fontFamily: { ascii: 'Calibri' } }); - }); - - it('returns empty object when w:docDefaults is missing', () => { - const params: OoxmlResolverParams = { - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:style', - attributes: { 'w:styleId': 'Normal' }, - }, - ], + abstracts: { + '10': { + levels: { + '0': { paragraphProperties: { spacing: { before: 240 } } }, }, - ], + }, }, }, - }; - const result = getDefaultProperties(params, mockRPrTranslator); - expect(result).toEqual({}); - }); - - it('returns empty object when styles.xml is missing', () => { - const params: OoxmlResolverParams = { docx: {} }; - const result = getDefaultProperties(params, mockRPrTranslator); - expect(result).toEqual({}); - }); - - it('returns empty object when docx is missing', () => { - const params: OoxmlResolverParams = {}; - const result = getDefaultProperties(params, mockRPrTranslator); - expect(result).toEqual({}); + }); + const result = getNumberingProperties('paragraphProperties', params, 0, 1); + expect(result).toEqual({ spacing: { before: 240, after: 120 } }); }); - it('returns empty object when elements are empty', () => { - const params: OoxmlResolverParams = { - docx: { - 'word/styles.xml': { - elements: [], - }, - }, - }; - const result = getDefaultProperties(params, mockRPrTranslator); + it('returns empty object when numbering definition is missing', () => { + const params = buildParams(); + const result = getNumberingProperties('paragraphProperties', params, 0, 999); expect(result).toEqual({}); }); +}); - it('handles w:pPrDefault for paragraph defaults', () => { - const params: OoxmlResolverParams = { - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:docDefaults', - elements: [ - { - name: 'w:pPrDefault', - elements: [ - { - name: 'w:pPr', - mockData: { spacing: { before: 0, after: 0 } }, - }, - ], - }, - ], - }, - ], - }, - ], - }, - }, - }; - const result = getDefaultProperties(params, mockPPrTranslator); - expect(result).toEqual({ spacing: { before: 0, after: 0 } }); +describe('ooxml - resolveDocxFontFamily', () => { + it('extracts ascii font when available', () => { + const result = resolveDocxFontFamily({ ascii: 'Calibri' }, null); + expect(result).toBe('Calibri'); }); - it('returns empty object when translator.encode returns null', () => { - const nullTranslator: OoxmlTranslator = { - xmlName: 'w:rPr', - encode: vi.fn(() => null), - }; - const params: OoxmlResolverParams = { - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:docDefaults', - elements: [ - { - name: 'w:rPrDefault', - elements: [{ name: 'w:rPr' }], - }, - ], - }, - ], - }, - ], - }, - }, - }; - const result = getDefaultProperties(params, nullTranslator); - expect(result).toEqual({}); + it('returns null when attributes is not an object', () => { + expect(resolveDocxFontFamily(null, null)).toBeNull(); + expect(resolveDocxFontFamily(undefined, null)).toBeNull(); + expect(resolveDocxFontFamily('invalid' as never, null)).toBeNull(); }); }); -describe('ooxml - getStyleProperties', () => { - it('extracts style properties and metadata', () => { - const params: OoxmlResolverParams = { - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:style', - attributes: { 'w:styleId': 'Heading1', 'w:default': '1' }, - elements: [ - { - name: 'w:basedOn', - attributes: { 'w:val': 'Normal' }, - }, - { - name: 'w:rPr', - mockData: { fontSize: 32, bold: true }, - }, - ], - }, - ], - }, - ], +describe('ooxml - resolveRunProperties', () => { + it('returns resolved run properties with defaults', () => { + const params = buildParams({ + translatedLinkedStyles: { + ...emptyStyles, + docDefaults: { runProperties: { fontSize: 20 } }, + styles: { + Normal: { default: true, runProperties: { fontSize: 22 } }, }, }, - }; - const result = getStyleProperties(params, 'Heading1', mockRPrTranslator); - expect(result).toEqual({ - properties: { fontSize: 32, bold: true }, - isDefault: true, - basedOn: 'Normal', }); + const result = resolveRunProperties(params, null, null); + expect(result).toHaveProperty('fontSize', 22); }); - it('returns empty result when styleId is not provided', () => { - const params: OoxmlResolverParams = { docx: {} }; - const result = getStyleProperties(params, '', mockRPrTranslator); - expect(result).toEqual({ properties: {}, isDefault: false, basedOn: null }); - }); - - it('returns empty result when style is not found', () => { - const params: OoxmlResolverParams = { - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:style', - attributes: { 'w:styleId': 'OtherStyle' }, - }, - ], - }, - ], + it('prefers defaults over Normal when Normal is not default', () => { + const params = buildParams({ + translatedLinkedStyles: { + ...emptyStyles, + docDefaults: { runProperties: { fontSize: 20, color: { val: 'AAAAAA' } } }, + styles: { + Normal: { default: false, runProperties: { fontSize: 22, color: { val: 'BBBBBB' } } }, }, }, - }; - const result = getStyleProperties(params, 'MissingStyle', mockRPrTranslator); - // When style is not found, basedOn is undefined (not extracted from undefined style) - expect(result).toEqual({ properties: {}, isDefault: false, basedOn: undefined }); + }); + const result = resolveRunProperties(params, null, null); + expect(result).toEqual({ fontSize: 20, color: { val: 'AAAAAA' } }); }); - it('extracts basedOn even when properties are missing', () => { - const params: OoxmlResolverParams = { - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:style', - attributes: { 'w:styleId': 'DerivedStyle' }, - elements: [ - { - name: 'w:basedOn', - attributes: { 'w:val': 'BaseStyle' }, - }, - ], - }, - ], - }, - ], + it('skips run style props for TOC paragraphs', () => { + const params = buildParams({ + translatedLinkedStyles: { + ...emptyStyles, + styles: { + TOC1: { runProperties: { bold: true } }, + Emphasis: { runProperties: { italic: true } }, }, }, - }; - const result = getStyleProperties(params, 'DerivedStyle', mockRPrTranslator); - expect(result).toEqual({ properties: {}, isDefault: false, basedOn: 'BaseStyle' }); + }); + const result = resolveRunProperties(params, { styleId: 'Emphasis', color: { val: 'FF0000' } }, { styleId: 'TOC1' }); + expect(result.bold).toBe(true); + expect(result.italic).toBeUndefined(); + expect(result.color).toEqual({ val: 'FF0000' }); }); - it('sets isDefault to false when w:default is not "1"', () => { - const params: OoxmlResolverParams = { - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:style', - attributes: { 'w:styleId': 'CustomStyle', 'w:default': '0' }, - elements: [ - { - name: 'w:rPr', - mockData: { fontSize: 22 }, - }, - ], - }, - ], + it('ignores inline rPr for list numbers when numbering is not inline', () => { + const params = buildParams({ + translatedNumbering: { + definitions: { '1': { abstractNumId: 10 } }, + abstracts: { + '10': { + levels: { + '0': { runProperties: { bold: false, color: { val: '00FF00' } } }, }, - ], - }, - }, - }; - const result = getStyleProperties(params, 'CustomStyle', mockRPrTranslator); - expect(result.isDefault).toBe(false); - }); - - it('handles missing elements array', () => { - const params: OoxmlResolverParams = { - docx: { - 'word/styles.xml': { - elements: [], + }, }, }, - }; - const result = getStyleProperties(params, 'AnyStyle', mockRPrTranslator); - expect(result).toEqual({ properties: {}, isDefault: false, basedOn: null }); - }); - - it('returns empty properties when translator.encode returns null', () => { - const nullTranslator: OoxmlTranslator = { - xmlName: 'w:rPr', - encode: vi.fn(() => null), - }; - const params: OoxmlResolverParams = { - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:style', - attributes: { 'w:styleId': 'Style1' }, - elements: [{ name: 'w:rPr' }], - }, - ], + }); + const result = resolveRunProperties( + params, + { underline: { val: 'single' }, bold: true }, + { numberingProperties: { numId: 1, ilvl: 0 } }, + null, + true, + false, + ); + expect(result.bold).toBe(false); + expect(result.underline).toBeUndefined(); + expect(result.color).toEqual({ val: '00FF00' }); + }); + + it('applies table cell run properties in cascade order', () => { + const params = buildParams({ + translatedLinkedStyles: { + ...emptyStyles, + styles: { + TableStyle1: { + type: 'table', + runProperties: { color: { val: 'AAAAAA' } }, + tableProperties: { tableStyleRowBandSize: 1, tableStyleColBandSize: 1 }, + tableStyleProperties: { + wholeTable: { runProperties: { bold: true, fontSize: 10 } }, + band1Horz: { runProperties: { italic: true, color: { val: 'BBBBBB' }, fontSize: 11 } }, + band1Vert: { runProperties: { color: { val: 'CCCCCC' }, fontSize: 12 } }, + firstRow: { runProperties: { fontSize: 13 } }, + firstCol: { runProperties: { fontSize: 14 } }, + nwCell: { runProperties: { fontSize: 15 } }, }, - ], - }, - }, - }; - const result = getStyleProperties(params, 'Style1', nullTranslator); - expect(result.properties).toEqual({}); - }); -}); - -describe('ooxml - getNumberingProperties', () => { - const translators = { pPr: mockPPrTranslator, rPr: mockRPrTranslator }; - - it('returns empty object when numbering is null', () => { - const params: OoxmlResolverParams = { numbering: null }; - const result = getNumberingProperties(translators, params, 0, 1, mockRPrTranslator); - expect(result).toEqual({}); - }); - - it('returns empty object when numbering is undefined', () => { - const params: OoxmlResolverParams = {}; - const result = getNumberingProperties(translators, params, 0, 1, mockRPrTranslator); - expect(result).toEqual({}); - }); - - it('returns empty object when definitions or abstracts are missing', () => { - const params: OoxmlResolverParams = { - numbering: { definitions: {}, abstracts: undefined }, - }; - const result = getNumberingProperties(translators, params, 0, 1, mockRPrTranslator); - expect(result).toEqual({}); - }); - - it('returns empty object when numId definition is not found', () => { - const params: OoxmlResolverParams = { - numbering: { - definitions: { num2: {} }, - abstracts: {}, - }, - }; - const result = getNumberingProperties(translators, params, 0, 1, mockRPrTranslator); - expect(result).toEqual({}); - }); - - it('extracts properties from abstractNum level definition', () => { - const params: OoxmlResolverParams = { - numbering: { - definitions: { - num1: { - elements: [ - { - name: 'w:abstractNumId', - attributes: { 'w:val': 'abstract1' }, - }, - ], - }, - }, - abstracts: { - abstract1: { - elements: [ - { - name: 'w:lvl', - attributes: { 'w:ilvl': '0' }, - elements: [ - { - name: 'w:rPr', - mockData: { bold: true, fontSize: 22 }, - }, - ], - }, - ], }, }, }, - }; - const result = getNumberingProperties(translators, params, 0, 'num1', mockRPrTranslator); - expect(result).toEqual({ bold: true, fontSize: 22 }); + }); + const tableInfo = { + tableProperties: { tableStyleId: 'TableStyle1', tblLook: { firstRow: true, firstColumn: true } }, + rowIndex: 0, + cellIndex: 0, + numRows: 2, + numCells: 2, + }; + const result = resolveRunProperties(params, {}, null, tableInfo); + expect(result.fontSize).toBe(15); + expect(result.bold).toBe(true); + expect(result.italic).toBe(true); + expect(result.color).toEqual({ val: 'CCCCCC' }); }); +}); - it('applies lvlOverride over abstractNum properties', () => { - const params: OoxmlResolverParams = { - numbering: { - definitions: { - num1: { - elements: [ - { - name: 'w:abstractNumId', - attributes: { 'w:val': 'abstract1' }, - }, - { - name: 'w:lvlOverride', - attributes: { 'w:ilvl': '0' }, - elements: [ - { - name: 'w:rPr', - mockData: { fontSize: 24, italic: true }, - }, - ], - }, - ], - }, - }, - abstracts: { - abstract1: { - elements: [ - { - name: 'w:lvl', - attributes: { 'w:ilvl': '0' }, - elements: [ - { - name: 'w:rPr', - mockData: { fontSize: 22, bold: true }, - }, - ], - }, - ], - }, +describe('ooxml - resolveParagraphProperties', () => { + it('combines defaults, Normal, and inline props', () => { + const params = buildParams({ + translatedLinkedStyles: { + ...emptyStyles, + docDefaults: { paragraphProperties: { spacing: { before: 240 } } }, + styles: { + Normal: { default: true, paragraphProperties: { spacing: { after: 120 } } }, }, }, - }; - const result = getNumberingProperties(translators, params, 0, 'num1', mockRPrTranslator); - expect(result).toEqual({ - fontSize: 24, // Override wins - italic: true, // From override - bold: true, // From abstract }); + const inlineProps = { spacing: { before: 480 } }; + const result = resolveParagraphProperties(params, inlineProps); + expect(result.spacing).toEqual({ before: 480, after: 120 }); }); - it('follows numStyleLink when present and tries < 1', () => { - // This test verifies that when an abstractNum has a numStyleLink, it follows - // the linked style and recursively resolves numbering from that style's numId - const params: OoxmlResolverParams = { - numbering: { - definitions: { - num1: { - elements: [ - { - name: 'w:abstractNumId', - attributes: { 'w:val': 'abstract1' }, - }, - ], - }, - 2: { - elements: [ - { - name: 'w:abstractNumId', - attributes: { 'w:val': 'abstract2' }, - }, - ], - }, - }, - abstracts: { - abstract1: { - elements: [ - { - name: 'w:numStyleLink', - attributes: { 'w:val': 'ListStyle1' }, - }, - { - name: 'w:lvl', - attributes: { 'w:ilvl': '0' }, - elements: [ - { - name: 'w:rPr', - mockData: { fontSize: 22 }, // This should be overridden by recursive call - }, - ], - }, - ], - }, - abstract2: { - elements: [ - { - name: 'w:lvl', - attributes: { 'w:ilvl': '0' }, - elements: [ - { - name: 'w:rPr', - mockData: { bold: true }, - }, - ], - }, - ], - }, + it('lets numbering override style indent when numbering is defined inline', () => { + const params = buildParams({ + translatedLinkedStyles: { + ...emptyStyles, + styles: { + ListStyle: { paragraphProperties: { indent: { left: 1200 } } }, }, }, - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:style', - attributes: { 'w:styleId': 'ListStyle1' }, - elements: [ - { - name: 'w:pPr', - mockData: { - numberingProperties: { numId: 2 }, - }, - }, - ], - }, - ], - }, - ], - }, - }, - }; - - // Mock pPr translator to extract numberingProperties from style - const pPrTranslatorWithNumPr: OoxmlTranslator = { - xmlName: 'w:pPr', - encode: vi.fn((params) => { - const nodes = (params as { nodes?: Array> })?.nodes; - if (!nodes || nodes.length === 0) return {}; - return (nodes[0]?.mockData as Record) || {}; - }), - }; - - const result = getNumberingProperties( - { pPr: pPrTranslatorWithNumPr, rPr: mockRPrTranslator }, - params, - 0, - 'num1', - mockRPrTranslator, - 0, // tries = 0 - ); - expect(result).toEqual({ bold: true }); - }); - - it('extracts pStyle from level definition', () => { - const params: OoxmlResolverParams = { - numbering: { - definitions: { - num1: { - elements: [ - { - name: 'w:abstractNumId', - attributes: { 'w:val': 'abstract1' }, - }, - ], - }, - }, + translatedNumbering: { + definitions: { '1': { abstractNumId: 10 } }, abstracts: { - abstract1: { - elements: [ - { - name: 'w:lvl', - attributes: { 'w:ilvl': '0' }, - elements: [ - { - name: 'w:pStyle', - attributes: { 'w:val': 'ListParagraph' }, - }, - { - name: 'w:rPr', - mockData: { bold: true }, - }, - ], - }, - ], + '10': { + levels: { + '0': { paragraphProperties: { indent: { left: 720 } } }, + }, }, }, }, - }; - const result = getNumberingProperties(translators, params, 0, 'num1', mockRPrTranslator); - expect(result).toEqual({ bold: true, styleId: 'ListParagraph' }); - }); - - it('handles numeric ilvl matching', () => { - const params: OoxmlResolverParams = { - numbering: { - definitions: { - num1: { - elements: [ - { - name: 'w:abstractNumId', - attributes: { 'w:val': 'abstract1' }, - }, - ], + }); + const result = resolveParagraphProperties(params, { + styleId: 'ListStyle', + numberingProperties: { numId: 1, ilvl: 0 }, + }); + expect(result.indent?.left).toBe(720); + }); + + it('uses numbering style but ignores basedOn chain for indentation', () => { + const params = buildParams({ + translatedLinkedStyles: { + ...emptyStyles, + styles: { + BaseStyle: { paragraphProperties: { indent: { left: 2000 } } }, + NumberedStyle: { + basedOn: 'BaseStyle', + paragraphProperties: { numberingProperties: { numId: 1, ilvl: 0 } }, }, }, + }, + translatedNumbering: { + definitions: { '1': { abstractNumId: 10 } }, abstracts: { - abstract1: { - elements: [ - { - name: 'w:lvl', - attributes: { 'w:ilvl': 0 }, // Numeric - elements: [ - { - name: 'w:rPr', - mockData: { fontSize: 22 }, - }, - ], - }, - ], + '10': { + levels: { + '0': { paragraphProperties: { indent: { left: 800 } }, styleId: 'NumberedStyle' }, + }, }, }, }, - }; - const result = getNumberingProperties(translators, params, 0, 'num1', mockRPrTranslator); - expect(result).toEqual({ fontSize: 22 }); - }); - - it('returns empty object when abstractNum is missing', () => { - const params: OoxmlResolverParams = { - numbering: { - definitions: { - num1: { - elements: [ - { - name: 'w:abstractNumId', - attributes: { 'w:val': 'missingAbstract' }, - }, - ], - }, + }); + const inlineProps = { numberingProperties: { numId: 1, ilvl: 0 } }; + const result = resolveParagraphProperties(params, inlineProps); + expect(result.indent?.left).toBe(800); + }); + + it('overrides tabStops across the cascade', () => { + const params = buildParams({ + translatedLinkedStyles: { + ...emptyStyles, + docDefaults: { paragraphProperties: { tabStops: [{ pos: 720 }] } }, + styles: { + Normal: { default: true, paragraphProperties: { tabStops: [{ pos: 1440 }] } }, }, - abstracts: {}, }, - }; - const result = getNumberingProperties(translators, params, 0, 'num1', mockRPrTranslator); - expect(result).toEqual({}); - }); - - it('returns empty object when level definition is not found', () => { - const params: OoxmlResolverParams = { - numbering: { - definitions: { - num1: { - elements: [ - { - name: 'w:abstractNumId', - attributes: { 'w:val': 'abstract1' }, - }, - ], - }, - }, - abstracts: { - abstract1: { - elements: [ - { - name: 'w:lvl', - attributes: { 'w:ilvl': '1' }, // Different level - elements: [], - }, - ], + }); + const result = resolveParagraphProperties(params, { tabStops: [{ pos: 2160 }] }); + expect(result.tabStops).toEqual([{ pos: 2160 }]); + }); + + it('applies table cell paragraph properties over table style props', () => { + const params = buildParams({ + translatedLinkedStyles: { + ...emptyStyles, + styles: { + TableStyle1: { + type: 'table', + paragraphProperties: { spacing: { before: 120, after: 120 }, keepNext: true }, + tableProperties: { tableStyleRowBandSize: 1, tableStyleColBandSize: 1 }, + tableStyleProperties: { + firstRow: { paragraphProperties: { spacing: { after: 240 } } }, + }, }, }, }, + }); + const tableInfo = { + tableProperties: { tableStyleId: 'TableStyle1', tblLook: { firstRow: true } }, + rowIndex: 0, + cellIndex: 2, + numRows: 3, + numCells: 4, }; - const result = getNumberingProperties(translators, params, 0, 'num1', mockRPrTranslator); - expect(result).toEqual({}); + const result = resolveParagraphProperties(params, {}, tableInfo); + expect(result.spacing).toEqual({ before: 120, after: 240 }); + expect(result.keepNext).toBe(true); }); }); -describe('ooxml - resolveDocxFontFamily', () => { - it('extracts ascii font when available', () => { - const attributes = { 'w:ascii': 'Calibri', 'w:hAnsi': 'Arial' }; - const result = resolveDocxFontFamily(attributes, null); - expect(result).toBe('Calibri'); - }); - - it('returns null when attributes is null', () => { - const result = resolveDocxFontFamily(null, null); - expect(result).toBeNull(); - }); - - it('returns null when attributes is undefined', () => { - const result = resolveDocxFontFamily(undefined, null); - expect(result).toBeNull(); - }); - - it('returns null when attributes is not an object', () => { - expect(resolveDocxFontFamily('not-an-object' as never, null)).toBeNull(); - expect(resolveDocxFontFamily(123 as never, null)).toBeNull(); - }); - - it('extracts ascii without w: prefix', () => { - const attributes = { ascii: 'Arial' }; - const result = resolveDocxFontFamily(attributes, null); - expect(result).toBe('Arial'); - }); - - it('resolves theme font when asciiTheme is present', () => { - const attributes = { 'w:asciiTheme': 'minorHAnsi' }; - const docx = { - 'word/theme/theme1.xml': { - elements: [ - { - elements: [ - { - name: 'a:themeElements', - elements: [ - { - name: 'a:fontScheme', - elements: [ - { - name: 'a:minorFont', - elements: [ - { - name: 'a:latin', - attributes: { typeface: 'Calibri' }, - }, - ], - }, - ], - }, - ], - }, - ], - }, - ], - }, - }; - const result = resolveDocxFontFamily(attributes, docx); - expect(result).toBe('Calibri'); - }); - - it('resolves major theme font', () => { - const attributes = { 'w:asciiTheme': 'majorHAnsi' }; - const docx = { - 'word/theme/theme1.xml': { - elements: [ - { - elements: [ - { - name: 'a:themeElements', - elements: [ - { - name: 'a:fontScheme', - elements: [ - { - name: 'a:majorFont', - elements: [ - { - name: 'a:latin', - attributes: { typeface: 'Cambria' }, - }, - ], - }, - ], - }, - ], - }, - ], - }, - ], - }, - }; - const result = resolveDocxFontFamily(attributes, docx); - expect(result).toBe('Cambria'); - }); - - it('falls back to ascii when theme resolution fails', () => { - const attributes = { 'w:ascii': 'Arial', 'w:asciiTheme': 'minorHAnsi' }; - const docx = {}; // No theme - const result = resolveDocxFontFamily(attributes, docx); - expect(result).toBe('Arial'); - }); - - it('applies toCssFontFamily callback when provided', () => { - const attributes = { 'w:ascii': 'Calibri' }; - const toCssFontFamily = (fontName: string) => `${fontName}, sans-serif`; - const result = resolveDocxFontFamily(attributes, null, toCssFontFamily); - expect(result).toBe('Calibri, sans-serif'); - }); - - it('returns null when no font is found', () => { - const attributes = {}; - const result = resolveDocxFontFamily(attributes, null); - expect(result).toBeNull(); - }); - - it('handles asciiTheme without w: prefix', () => { - const attributes = { asciiTheme: 'minorHAnsi' }; - const docx = { - 'word/theme/theme1.xml': { - elements: [ - { - elements: [ - { - name: 'a:themeElements', - elements: [ - { - name: 'a:fontScheme', - elements: [ - { - name: 'a:minorFont', - elements: [ - { - name: 'a:latin', - attributes: { typeface: 'Calibri' }, - }, - ], - }, - ], - }, - ], - }, - ], +describe('ooxml - resolveCellStyles', () => { + it('respects band sizes and tblLook flags', () => { + const params = buildParams({ + translatedLinkedStyles: { + ...emptyStyles, + styles: { + TableStyleBand: { + type: 'table', + tableProperties: { tableStyleRowBandSize: 2, tableStyleColBandSize: 3 }, + tableStyleProperties: { + wholeTable: { runProperties: { fontSize: 10 } }, + band1Vert: { runProperties: { fontSize: 20 } }, + band2Vert: { runProperties: { fontSize: 30 } }, + band1Horz: { runProperties: { fontSize: 40 } }, + band2Horz: { runProperties: { fontSize: 50 } }, + }, }, - ], + }, }, - }; - const result = resolveDocxFontFamily(attributes, docx); - expect(result).toBe('Calibri'); - }); -}); - -describe('ooxml - resolveRunProperties (smoke test)', () => { - it('returns an object with fontSize property', () => { - const translators = { pPr: mockPPrTranslator, rPr: mockRPrTranslator }; - const params: OoxmlResolverParams = { docx: {} }; - const result = resolveRunProperties(translators, params, null, null); - expect(result).toHaveProperty('fontSize'); - expect(typeof result.fontSize).toBe('number'); - }); - - it('accepts all required parameters', () => { - const translators = { pPr: mockPPrTranslator, rPr: mockRPrTranslator }; - const params: OoxmlResolverParams = { docx: {} }; - const inlineRpr = { fontSize: 24 }; - const resolvedPpr = { styleId: 'Normal' }; - const result = resolveRunProperties(translators, params, inlineRpr, resolvedPpr, false, false); - expect(result).toBeDefined(); - expect(typeof result).toBe('object'); - }); - - it('handles isListNumber flag', () => { - const translators = { pPr: mockPPrTranslator, rPr: mockRPrTranslator }; - const params: OoxmlResolverParams = { docx: {} }; - const result = resolveRunProperties(translators, params, null, null, true, false); - expect(result).toBeDefined(); - }); - - it('handles numberingDefinedInline flag', () => { - const translators = { pPr: mockPPrTranslator, rPr: mockRPrTranslator }; - const params: OoxmlResolverParams = { docx: {} }; - const result = resolveRunProperties(translators, params, null, null, false, true); - expect(result).toBeDefined(); - }); -}); - -describe('ooxml - resolveParagraphProperties (smoke test)', () => { - it('returns an object', () => { - const translators = { pPr: mockPPrTranslator, rPr: mockRPrTranslator }; - const params: OoxmlResolverParams = { docx: {} }; - const result = resolveParagraphProperties(translators, params, null); - expect(result).toBeDefined(); - expect(typeof result).toBe('object'); - }); - - it('accepts all optional parameters', () => { - const translators = { pPr: mockPPrTranslator, rPr: mockRPrTranslator }; - const params: OoxmlResolverParams = { docx: {} }; - const inlineProps = { styleId: 'Heading1' }; - const result = resolveParagraphProperties(translators, params, inlineProps, true, true, 'TableNormal'); - expect(result).toBeDefined(); - }); - - it('handles insideTable flag', () => { - const translators = { pPr: mockPPrTranslator, rPr: mockRPrTranslator }; - const params: OoxmlResolverParams = { docx: {} }; - const result = resolveParagraphProperties(translators, params, null, true); - expect(result).toBeDefined(); - }); - - it('handles overrideInlineStyleId flag', () => { - const translators = { pPr: mockPPrTranslator, rPr: mockRPrTranslator }; - const params: OoxmlResolverParams = { docx: {} }; - const result = resolveParagraphProperties(translators, params, null, false, true); - expect(result).toBeDefined(); - }); - - it('handles tableStyleId parameter', () => { - const translators = { pPr: mockPPrTranslator, rPr: mockRPrTranslator }; - const params: OoxmlResolverParams = { docx: {} }; - const result = resolveParagraphProperties(translators, params, null, false, false, 'TableGrid'); - expect(result).toBeDefined(); + }); + const tableInfo = { + tableProperties: { tableStyleId: 'TableStyleBand', tblLook: { noVBand: true } }, + rowIndex: 3, + cellIndex: 2, + numRows: 5, + numCells: 6, + }; + const result = resolveCellStyles('runProperties', tableInfo, params.translatedLinkedStyles!); + expect(result).toEqual([{ fontSize: 10 }, { fontSize: 50 }]); }); }); diff --git a/packages/layout-engine/style-engine/src/ooxml/index.ts b/packages/layout-engine/style-engine/src/ooxml/index.ts index 0ed0dd9b99..33b0fd7991 100644 --- a/packages/layout-engine/style-engine/src/ooxml/index.ts +++ b/packages/layout-engine/style-engine/src/ooxml/index.ts @@ -6,392 +6,307 @@ */ import { - applyInlineOverrides, combineIndentProperties, combineProperties, combineRunProperties, - createFirstLineIndentHandler, - DEFAULT_FONT_SIZE_HALF_POINTS, - INLINE_OVERRIDE_PROPERTIES, - isValidFontSize, orderDefaultsAndNormal, - resolveFontSizeWithFallback, } from '../cascade.js'; -import type { CombinePropertiesOptions, PropertyObject, SpecialHandler } from '../cascade.js'; +import type { PropertyObject } from '../cascade.js'; +import type { ParagraphProperties, RunProperties } from './types.ts'; +import type { NumberingProperties } from './numbering-types.ts'; +import type { StylesDocumentProperties, TableStyleType, TableProperties, TableLookProperties } from './styles-types.ts'; -export { - applyInlineOverrides, - combineIndentProperties, - combineProperties, - combineRunProperties, - createFirstLineIndentHandler, - DEFAULT_FONT_SIZE_HALF_POINTS, - INLINE_OVERRIDE_PROPERTIES, - isValidFontSize, - orderDefaultsAndNormal, - resolveFontSizeWithFallback, -}; -export type { CombinePropertiesOptions, PropertyObject, SpecialHandler }; - -export interface OoxmlTranslator { - xmlName: string; - encode: (params: unknown) => Record | null | undefined; -} - -export interface OoxmlTranslators { - pPr: OoxmlTranslator; - rPr: OoxmlTranslator; -} - -export interface OoxmlNumberingContext { - definitions?: Record; - abstracts?: Record; -} +export { combineIndentProperties, combineProperties, combineRunProperties, orderDefaultsAndNormal }; +export type { PropertyObject }; +export type * from './types.ts'; +export type * from './numbering-types.ts'; +export type * from './styles-types.ts'; export interface OoxmlResolverParams { - docx?: Record; - numbering?: OoxmlNumberingContext | null; + translatedNumbering: NumberingProperties | null | undefined; + translatedLinkedStyles: StylesDocumentProperties | null | undefined; } -export function createOoxmlResolver(translators: OoxmlTranslators) { - return { - resolveRunProperties: ( - params: OoxmlResolverParams, - inlineRpr: Record | null | undefined, - resolvedPpr: Record | null | undefined, - isListNumber = false, - numberingDefinedInline = false, - ) => resolveRunProperties(translators, params, inlineRpr, resolvedPpr, isListNumber, numberingDefinedInline), - resolveParagraphProperties: ( - params: OoxmlResolverParams, - inlineProps: Record | null | undefined, - insideTable = false, - overrideInlineStyleId = false, - tableStyleId: string | null = null, - ) => resolveParagraphProperties(translators, params, inlineProps, insideTable, overrideInlineStyleId, tableStyleId), - getDefaultProperties, - getStyleProperties, - resolveStyleChain, - getNumberingProperties: ( - params: OoxmlResolverParams, - ilvl: number, - numId: number | string, - translator: OoxmlTranslator, - tries = 0, - ) => getNumberingProperties(translators, params, ilvl, numId, translator, tries), - }; +export interface TableInfo { + tableProperties: TableProperties | null | undefined; + rowIndex: number; + cellIndex: number; + numCells: number; + numRows: number; } export function resolveRunProperties( - translators: OoxmlTranslators, params: OoxmlResolverParams, - inlineRpr: Record | null | undefined, - resolvedPpr: Record | null | undefined, + inlineRpr: RunProperties | null | undefined, + resolvedPpr: ParagraphProperties | null | undefined, + tableInfo: TableInfo | null | undefined = null, isListNumber = false, numberingDefinedInline = false, -): Record { +): RunProperties { + if (!params.translatedLinkedStyles) { + return inlineRpr ?? {}; + } + if (!inlineRpr) { + inlineRpr = {} as RunProperties; + } + // Getting properties from style const paragraphStyleId = resolvedPpr?.styleId as string | undefined; - const paragraphStyleProps = resolveStyleChain(params, paragraphStyleId, translators.rPr); - - const defaultProps = getDefaultProperties(params, translators.rPr); - const { properties: normalProps, isDefault: isNormalDefault } = getStyleProperties(params, 'Normal', translators.rPr); + const paragraphStyleProps = resolveStyleChain('runProperties', params, paragraphStyleId) as RunProperties; + + // Getting default properties and normal style properties + const defaultProps = params.translatedLinkedStyles.docDefaults?.runProperties ?? {}; + const normalStyleDef = params.translatedLinkedStyles.styles['Normal']; + const normalProps = (normalStyleDef?.runProperties ?? {}) as RunProperties; + const isNormalDefault = normalStyleDef?.default ?? false; + + // Getting table style run properties + const tableStyleProps = ( + tableInfo?.tableProperties?.tableStyleId + ? resolveStyleChain('runProperties', params, tableInfo?.tableProperties?.tableStyleId) + : {} + ) as RunProperties; + + // Getting cell style run properties + const cellStyleProps: RunProperties[] = resolveCellStyles( + 'runProperties', + tableInfo, + params.translatedLinkedStyles, + ); - let runStyleProps: Record = {}; + // Get run properties from direct character style, unless it's inside a TOC paragraph style + let runStyleProps = {} as RunProperties; if (!paragraphStyleId?.startsWith('TOC')) { - runStyleProps = inlineRpr?.styleId ? resolveStyleChain(params, inlineRpr.styleId as string, translators.rPr) : {}; + runStyleProps = ( + inlineRpr?.styleId ? resolveStyleChain('runProperties', params, inlineRpr.styleId as string) : {} + ) as RunProperties; } const defaultsChain = orderDefaultsAndNormal(defaultProps, normalProps, isNormalDefault); - const inlineRprSafe = inlineRpr ?? {}; - let styleChain: PropertyObject[]; - let inlineOverrideSource: Record = inlineRprSafe; + let styleChain: RunProperties[]; if (isListNumber) { - let numberingProps: Record = {}; - const numberingProperties = resolvedPpr?.numberingProperties as Record | undefined; - const numId = numberingProperties?.numId as number | string | undefined; - if (numId != null && numId !== 0 && numId !== '0') { - numberingProps = getNumberingProperties( - translators, - params, - (numberingProperties?.ilvl as number | undefined) ?? 0, - numId, - translators.rPr, - ); + const numberingProperties = resolvedPpr?.numberingProperties; + const numId = resolvedPpr?.numberingProperties?.numId; + let numberingProps: RunProperties = {} as RunProperties; + if (numId != null && numId !== 0) { + numberingProps = getNumberingProperties('runProperties', params, numberingProperties?.ilvl ?? 0, numId); } - const inlineRprForList = numberingDefinedInline ? inlineRprSafe : {}; - if (inlineRprForList?.underline) { - delete inlineRprForList.underline; + if (!numberingDefinedInline) { + // If numbering is not defined inline, we need to ignore the inline rPr + inlineRpr = {} as RunProperties; } - styleChain = [...defaultsChain, paragraphStyleProps, runStyleProps, inlineRprForList, numberingProps]; - inlineOverrideSource = inlineRprForList; + // Inline underlines are ignored for list numbers + if (inlineRpr?.underline) { + delete inlineRpr.underline; + } + + styleChain = [ + ...defaultsChain, + tableStyleProps, + ...cellStyleProps, + paragraphStyleProps, + runStyleProps, + inlineRpr, + numberingProps, + ]; } else { - styleChain = [...defaultsChain, paragraphStyleProps, runStyleProps, inlineRprSafe]; + styleChain = [...defaultsChain, tableStyleProps, ...cellStyleProps, paragraphStyleProps, runStyleProps, inlineRpr]; } const finalProps = combineRunProperties(styleChain); - applyInlineOverrides(finalProps, inlineOverrideSource); - finalProps.fontSize = resolveFontSizeWithFallback(finalProps.fontSize, defaultProps, normalProps); - return finalProps; } export function resolveParagraphProperties( - translators: OoxmlTranslators, params: OoxmlResolverParams, - inlineProps: Record | null | undefined, - insideTable = false, - overrideInlineStyleId = false, - tableStyleId: string | null = null, -): Record { - const defaultProps = getDefaultProperties(params, translators.pPr); - const { properties: normalProps, isDefault: isNormalDefault } = getStyleProperties(params, 'Normal', translators.pPr); - - const inlinePropsSafe = inlineProps ?? {}; - let styleId = inlinePropsSafe?.styleId as string | undefined; - let styleProps = inlinePropsSafe?.styleId - ? resolveStyleChain(params, inlinePropsSafe.styleId as string, translators.pPr) - : {}; - - let numberingProps: Record = {}; - const ilvl = - (inlinePropsSafe?.numberingProperties as Record | undefined)?.ilvl ?? - (styleProps?.numberingProperties as Record | undefined)?.ilvl; - let numId = - (inlinePropsSafe?.numberingProperties as Record | undefined)?.numId ?? - (styleProps?.numberingProperties as Record | undefined)?.numId; - let numberingDefinedInline = - (inlinePropsSafe?.numberingProperties as Record | undefined)?.numId != null; - - const inlineNumId = (inlinePropsSafe?.numberingProperties as Record | undefined)?.numId; - const inlineNumIdDisablesNumbering = inlineNumId === 0 || inlineNumId === '0'; - if (inlineNumIdDisablesNumbering) { - numId = null; + inlineProps: ParagraphProperties | null | undefined, + tableInfo: TableInfo | null | undefined, +): ParagraphProperties { + if (!inlineProps) { + inlineProps = {} as ParagraphProperties; + } + if (!params.translatedLinkedStyles) { + return inlineProps; } - const isList = numId != null && numId !== 0 && numId !== '0'; + // Normal style and default properties + const defaultProps = params.translatedLinkedStyles.docDefaults?.paragraphProperties ?? {}; + const normalStyleDef = params.translatedLinkedStyles.styles['Normal']; + const normalProps = (normalStyleDef?.paragraphProperties ?? {}) as ParagraphProperties; + const isNormalDefault = normalStyleDef?.default ?? false; + + // Properties from styles + let styleId = inlineProps.styleId as string | undefined; + let styleProps = ( + inlineProps.styleId ? resolveStyleChain('paragraphProperties', params, inlineProps.styleId) : {} + ) as ParagraphProperties; + + // Properties from numbering + let numberingProps = {} as ParagraphProperties; + const ilvl = inlineProps?.numberingProperties?.ilvl ?? styleProps?.numberingProperties?.ilvl; + const numId = inlineProps?.numberingProperties?.numId ?? styleProps?.numberingProperties?.numId; + let numberingDefinedInline = inlineProps?.numberingProperties?.numId != null; + + const isList = numId != null && numId !== 0; if (isList) { const ilvlNum = ilvl != null ? (ilvl as number) : 0; - numberingProps = getNumberingProperties(translators, params, ilvlNum, numId as number | string, translators.pPr); - if (overrideInlineStyleId && numberingProps.styleId) { + numberingProps = getNumberingProperties('paragraphProperties', params, ilvlNum, numId); + if (numberingProps.styleId) { + // If numbering level defines a style, replace styleProps with that style styleId = numberingProps.styleId as string; - styleProps = resolveStyleChain(params, styleId, translators.pPr); - if (inlinePropsSafe) { - inlinePropsSafe.styleId = styleId; - const inlineNumProps = inlinePropsSafe.numberingProperties as Record | undefined; - if ( - (styleProps.numberingProperties as Record | undefined)?.ilvl === inlineNumProps?.ilvl && - (styleProps.numberingProperties as Record | undefined)?.numId === inlineNumProps?.numId - ) { - delete inlinePropsSafe.numberingProperties; - numberingDefinedInline = false; - } + styleProps = resolveStyleChain('paragraphProperties', params, styleId); + inlineProps.styleId = styleId; + const inlineNumProps = inlineProps.numberingProperties; + if ( + styleProps.numberingProperties?.ilvl === inlineNumProps?.ilvl && + styleProps.numberingProperties?.numId === inlineNumProps?.numId + ) { + // Numbering is already defined in style, so remove from inline props + delete inlineProps.numberingProperties; + numberingDefinedInline = false; } } } - const tableProps = tableStyleId ? resolveStyleChain(params, tableStyleId, translators.pPr) : {}; + // Table properties + const tableProps = ( + tableInfo?.tableProperties?.tableStyleId + ? resolveStyleChain('paragraphProperties', params, tableInfo?.tableProperties?.tableStyleId) + : {} + ) as ParagraphProperties; + + // Cell style properties + const cellStyleProps: ParagraphProperties[] = resolveCellStyles( + 'paragraphProperties', + tableInfo, + params.translatedLinkedStyles, + ); + // Resolve property chain - regular properties are treated differently from indentation + // Chain for regular properties const defaultsChain = orderDefaultsAndNormal(defaultProps, normalProps, isNormalDefault); - const propsChain = [...defaultsChain, tableProps, numberingProps, styleProps, inlinePropsSafe]; + const propsChain = [...defaultsChain, tableProps, ...cellStyleProps, numberingProps, styleProps, inlineProps]; - let indentChain: PropertyObject[]; + // Chain for indentation properties + let indentChain: ParagraphProperties[]; if (isList) { if (numberingDefinedInline) { - indentChain = [...defaultsChain, styleProps, numberingProps, inlinePropsSafe]; + // If numbering is defined inline, then numberingProps should override styleProps for indentation + indentChain = [...defaultsChain, styleProps, numberingProps, inlineProps]; } else { - styleProps = resolveStyleChain(params, styleId, translators.pPr, false); - indentChain = [...defaultsChain, numberingProps, styleProps, inlinePropsSafe]; + // Otherwise, styleProps should override numberingProps for indentation but it should not follow the based-on chain + styleProps = resolveStyleChain('paragraphProperties', params, styleId, false); + indentChain = [...defaultsChain, numberingProps, styleProps, inlineProps]; } } else { - indentChain = [...defaultsChain, numberingProps, styleProps, inlinePropsSafe]; + indentChain = [...defaultsChain, styleProps, inlineProps]; } - const finalProps = combineProperties(propsChain); + const finalProps = combineProperties(propsChain, { + specialHandling: { + tabStops: (target: ParagraphProperties, source: ParagraphProperties): unknown => { + // If a higher priority source defines firstLine, remove hanging from the final result + if (target.tabStops != null && source.tabStops != null) { + return [...(target.tabStops as unknown[]), ...(source.tabStops as unknown[])]; + } + return source.tabStops; + }, + }, + }); const finalIndent = combineIndentProperties(indentChain); - finalProps.indent = (finalIndent as Record).indent; - - if (insideTable && !inlinePropsSafe?.spacing && !(styleProps as Record)?.spacing) { - finalProps.spacing = undefined; - } + finalProps.indent = finalIndent.indent; return finalProps; } -export function resolveStyleChain( +export function resolveStyleChain( + propertyType: 'paragraphProperties' | 'runProperties', params: OoxmlResolverParams, styleId: string | undefined, - translator: OoxmlTranslator, followBasedOnChain = true, -): Record { - let styleProps: Record = {}; - let basedOn: string | undefined = undefined; - if (styleId && styleId !== 'Normal') { - ({ properties: styleProps, basedOn } = getStyleProperties(params, styleId, translator)); - } +): T { + if (!styleId) return {} as T; + + const styleDef = params.translatedLinkedStyles?.styles?.[styleId]; + if (!styleDef) return {} as T; - let styleChain: Record[] = [styleProps]; + const styleProps = (styleDef[propertyType as keyof typeof styleDef] ?? {}) as T; + const basedOn = styleDef.basedOn; + + let styleChain: T[] = [styleProps]; const seenStyles = new Set(); let nextBasedOn = basedOn; while (followBasedOnChain && nextBasedOn) { - if (seenStyles.has(basedOn as string)) { + if (seenStyles.has(nextBasedOn as string)) { break; } seenStyles.add(basedOn as string); - const result = getStyleProperties(params, nextBasedOn, translator); - const basedOnProps = result.properties; - nextBasedOn = result.basedOn; + const basedOnStyleDef = params.translatedLinkedStyles?.styles?.[nextBasedOn]; + const basedOnProps = basedOnStyleDef?.[propertyType as keyof typeof basedOnStyleDef] as T; + if (basedOnProps && Object.keys(basedOnProps).length) { styleChain.push(basedOnProps); } - basedOn = nextBasedOn; + nextBasedOn = basedOnStyleDef?.basedOn; } styleChain = styleChain.reverse(); return combineProperties(styleChain); } -export function getDefaultProperties( - params: OoxmlResolverParams, - translator: OoxmlTranslator, -): Record { - const docx = params?.docx as Record | undefined; - const styles = docx?.['word/styles.xml'] as Record | undefined; - const rootElements = (styles as { elements?: Array> })?.elements?.[0]?.elements as - | Array> - | undefined; - if (!rootElements?.length) { - return {}; - } - - const defaults = rootElements.find((el) => el.name === 'w:docDefaults'); - const xmlName = translator.xmlName; - const defaultsElements = (defaults as Record)?.elements as - | Array> - | undefined; - const elementPrDefault = defaultsElements?.find((el) => el.name === `${xmlName}Default`); - const elementPrDefaultElements = elementPrDefault?.elements as Array> | undefined; - const elementPr = elementPrDefaultElements?.find((el) => el.name === xmlName); - if (!elementPr) { - return {}; - } - - return translator.encode({ ...params, nodes: [elementPr] }) || {}; -} - -export function getStyleProperties( - params: OoxmlResolverParams, - styleId: string, - translator: OoxmlTranslator, -): { properties: Record; isDefault: boolean; basedOn: string | undefined } { - const emptyResult = { properties: {}, isDefault: false, basedOn: undefined }; - if (!styleId) return emptyResult; - - const docx = params?.docx as Record | undefined; - const styles = docx?.['word/styles.xml'] as Record | undefined; - const rootElements = (styles as { elements?: Array> })?.elements?.[0]?.elements as - | Array> - | undefined; - if (!rootElements?.length) { - return emptyResult; - } - - const style = rootElements.find( - (el) => el.name === 'w:style' && (el.attributes as Record)?.['w:styleId'] === styleId, - ) as Record | undefined; - const styleElements = style?.elements as Array> | undefined; - const basedOnElement = styleElements?.find((el) => el.name === 'w:basedOn'); - const basedOn = (basedOnElement?.attributes as Record | undefined)?.['w:val'] as string | undefined; - const elementPr = styleElements?.find((el) => el.name === translator.xmlName); - if (!elementPr) { - return { ...emptyResult, basedOn }; - } - - const result = translator.encode({ ...params, nodes: [elementPr] }) || {}; - const isDefault = (style?.attributes as Record)?.['w:default'] === '1'; - - return { properties: result, isDefault, basedOn }; -} - -export function getNumberingProperties( - translators: OoxmlTranslators, +export function getNumberingProperties( + propertyType: 'paragraphProperties' | 'runProperties', params: OoxmlResolverParams, ilvl: number, - numId: number | string, - translator: OoxmlTranslator, + numId: number, tries = 0, -): Record { - const numbering = params?.numbering as OoxmlNumberingContext | null | undefined; - if (!numbering) return {}; +): T { + const numbering = params.translatedNumbering; + if (!numbering) return {} as T; const { definitions, abstracts } = numbering; - if (!definitions || !abstracts) return {}; + if (!definitions || !abstracts) return {} as T; - const propertiesChain: Record[] = []; + const propertiesChain: T[] = []; - const numDefinition = definitions[numId as keyof typeof definitions] as Record | undefined; - if (!numDefinition) return {}; + const numDefinition = definitions[String(numId)]; + if (!numDefinition) return {} as T; - const numDefElements = numDefinition.elements as Array> | undefined; - const lvlOverride = numDefElements?.find( - (element) => - element.name === 'w:lvlOverride' && - (element.attributes as Record | undefined)?.['w:ilvl'] == ilvl, - ); - const lvlOverrideElements = lvlOverride?.elements as Array> | undefined; - const overridePr = lvlOverrideElements?.find((el) => el.name === translator.xmlName); - if (overridePr) { - const overrideProps = translator.encode({ ...params, nodes: [overridePr] }) || {}; + const lvlOverride = numDefinition.lvlOverrides?.[String(ilvl)]; + const overrideProps = lvlOverride?.[propertyType as keyof typeof lvlOverride] as T; + + if (overrideProps) { propertiesChain.push(overrideProps); } - const abstractNumIdElement = numDefElements?.find((item) => item.name === 'w:abstractNumId'); - const abstractNumId = (abstractNumIdElement?.attributes as Record | undefined)?.['w:val'] as - | string - | undefined; + const abstractNumId = numDefinition.abstractNumId!; - const listDefinitionForThisNumId = abstracts[abstractNumId as keyof typeof abstracts] as - | Record - | undefined; - if (!listDefinitionForThisNumId) return {}; + const listDefinitionForThisNumId = abstracts[String(abstractNumId)]; + if (!listDefinitionForThisNumId) return {} as T; - const listDefElements = listDefinitionForThisNumId.elements as Array> | undefined; - const numStyleLink = listDefElements?.find((item) => item.name === 'w:numStyleLink'); - const styleId = (numStyleLink?.attributes as Record | undefined)?.['w:val'] as string | undefined; + const numStyleLinkId = listDefinitionForThisNumId.numStyleLink ?? listDefinitionForThisNumId.styleLink; - if (styleId && tries < 1) { - const { properties: styleProps } = getStyleProperties(params, styleId, translators.pPr); - const numIdFromStyle = (styleProps?.numberingProperties as Record | undefined)?.numId; + if (numStyleLinkId && tries < 1) { + const styleDef = params.translatedLinkedStyles?.styles?.[numStyleLinkId]; + const styleProps = styleDef?.paragraphProperties; + const numIdFromStyle = styleProps?.numberingProperties?.numId; if (numIdFromStyle) { - return getNumberingProperties( - translators, - params, - ilvl, - numIdFromStyle as number | string, - translator, - tries + 1, - ); + return getNumberingProperties(propertyType, params, ilvl, numIdFromStyle, tries + 1); } } - const levelDefinition = listDefElements?.find( - (element) => - element.name === 'w:lvl' && (element.attributes as Record | undefined)?.['w:ilvl'] == ilvl, - ); - if (!levelDefinition) return {}; + const levelDefinition = listDefinitionForThisNumId.levels?.[String(ilvl)]; + if (!levelDefinition) return {} as T; - const levelDefElements = levelDefinition.elements as Array> | undefined; - const abstractElementPr = levelDefElements?.find((el) => el.name === translator.xmlName); - if (!abstractElementPr) return {}; - const abstractProps = translator.encode({ ...params, nodes: [abstractElementPr] }) || {}; + const abstractProps = levelDefinition[propertyType as keyof typeof levelDefinition] as T; - const pStyleElement = levelDefElements?.find((el) => el.name === 'w:pStyle'); - if (pStyleElement) { - const pStyleId = (pStyleElement.attributes as Record | undefined)?.['w:val'] as string | undefined; - (abstractProps as Record).styleId = pStyleId; + if (abstractProps != null) { + if (levelDefinition?.styleId) { + abstractProps.styleId = levelDefinition?.styleId; + } + propertiesChain.push(abstractProps); } - propertiesChain.push(abstractProps as Record); propertiesChain.reverse(); return combineProperties(propertiesChain); @@ -404,8 +319,11 @@ export function resolveDocxFontFamily( ): string | null { if (!attributes || typeof attributes !== 'object') return null; - const ascii = (attributes['w:ascii'] ?? attributes['ascii']) as string | undefined; - const themeAscii = (attributes['w:asciiTheme'] ?? attributes['asciiTheme']) as string | undefined; + const ascii = (attributes['w:ascii'] ?? attributes['ascii'] ?? attributes['eastAsia']) as string | undefined; + let themeAscii = (attributes['w:asciiTheme'] ?? attributes['asciiTheme']) as string | undefined; + if ((!ascii && attributes.hint === 'default') || (!ascii && !themeAscii)) { + themeAscii = 'major'; + } let resolved = ascii; if (docx && themeAscii) { @@ -433,3 +351,104 @@ export function resolveDocxFontFamily( } return resolved; } + +export function resolveCellStyles( + propertyType: 'paragraphProperties' | 'runProperties', + tableInfo: TableInfo | null | undefined, + translatedLinkedStyles: StylesDocumentProperties, +): T[] { + if (tableInfo == null || !tableInfo.tableProperties?.tableStyleId) { + return []; + } + const cellStyleProps: T[] = []; + if (tableInfo != null && tableInfo.tableProperties.tableStyleId) { + const tableStyleDef = translatedLinkedStyles.styles[tableInfo.tableProperties.tableStyleId]; + const tableStylePropsDef = tableStyleDef?.tableProperties; + const rowBandSize = tableStylePropsDef?.tableStyleRowBandSize ?? 1; + const colBandSize = tableStylePropsDef?.tableStyleColBandSize ?? 1; + const cellStyleTypes = determineCellStyleTypes( + tableInfo.tableProperties?.tblLook, + tableInfo.rowIndex, + tableInfo.cellIndex, + tableInfo.numRows, + tableInfo.numCells, + rowBandSize, + colBandSize, + ); + cellStyleTypes.forEach((styleType) => { + const typeProps = tableStyleDef?.tableStyleProperties?.[styleType]?.[propertyType] as T; + if (typeProps) { + cellStyleProps.push(typeProps); + } + }); + } + return cellStyleProps; +} + +function determineCellStyleTypes( + tblLook: TableLookProperties | null | undefined, + rowIndex: number, + cellIndex: number, + numRows?: number | null, + numCells?: number | null, + rowBandSize = 1, + colBandSize = 1, +): TableStyleType[] { + const styleTypes: TableStyleType[] = ['wholeTable']; + + const normalizedRowBandSize = rowBandSize > 0 ? rowBandSize : 1; + const normalizedColBandSize = colBandSize > 0 ? colBandSize : 1; + const rowGroup = Math.floor(rowIndex / normalizedRowBandSize); + const colGroup = Math.floor(cellIndex / normalizedColBandSize); + + if (!tblLook?.noHBand) { + if (rowGroup % 2 === 0) { + styleTypes.push('band1Horz'); + } else { + styleTypes.push('band2Horz'); + } + } + + if (!tblLook?.noVBand) { + if (colGroup % 2 === 0) { + styleTypes.push('band1Vert'); + } else { + styleTypes.push('band2Vert'); + } + } + + if (tblLook?.firstRow && rowIndex === 0) { + styleTypes.push('firstRow'); + } + if (tblLook?.firstColumn && cellIndex === 0) { + styleTypes.push('firstCol'); + } + if (tblLook?.lastRow && numRows != null && numRows > 0 && rowIndex === numRows - 1) { + styleTypes.push('lastRow'); + } + if (tblLook?.lastColumn && numCells != null && numCells > 0 && cellIndex === numCells - 1) { + styleTypes.push('lastCol'); + } + + if (rowIndex === 0 && cellIndex === 0) { + styleTypes.push('nwCell'); + } + if (rowIndex === 0 && numCells != null && numCells > 0 && cellIndex === numCells - 1) { + styleTypes.push('neCell'); + } + if (numRows != null && numRows > 0 && rowIndex === numRows - 1 && cellIndex === 0) { + styleTypes.push('swCell'); + } + if ( + numRows != null && + numRows > 0 && + numCells != null && + numCells > 0 && + rowIndex === numRows - 1 && + cellIndex === numCells - 1 + ) { + styleTypes.push('seCell'); + } + + return styleTypes; +} diff --git a/packages/layout-engine/style-engine/src/ooxml/numbering-types.ts b/packages/layout-engine/style-engine/src/ooxml/numbering-types.ts new file mode 100644 index 0000000000..00073ac2c5 --- /dev/null +++ b/packages/layout-engine/style-engine/src/ooxml/numbering-types.ts @@ -0,0 +1,127 @@ +import type { ParagraphProperties, RunProperties } from './types'; + +/** + * Encoded properties for the w:numbering document. + */ +export interface NumberingProperties { + /** Numbering namespace identifier. */ + nsid?: number; + /** Numbering template identifier. */ + tmpl?: number; + /** Numbering name. */ + name?: string; + /** Style link identifier. */ + styleLink?: string; + /** Numbering style link identifier. */ + numStyleLink?: string; + /** Multi-level type value. */ + multiLevelType?: string; + /** Mac at cleanup numbering identifier. */ + numIdMacAtCleanup?: number; + /** Abstract numbering definitions keyed by abstractNumId. */ + abstracts?: Record; + /** Concrete numbering definitions keyed by numId. */ + definitions?: Record; +} + +/** + * Abstract numbering definition encoded from w:abstractNum. + */ +export interface AbstractNumberingDefinition { + /** Abstract numbering identifier. */ + abstractNumId?: number; + /** Numbering namespace identifier. */ + nsid?: number; + /** Numbering template identifier. */ + tmpl?: number; + /** Abstract numbering name. */ + name?: string; + /** Style link identifier. */ + styleLink?: string; + /** Numbering style link identifier. */ + numStyleLink?: string; + /** Multi-level type value. */ + multiLevelType?: string; + /** Level definitions keyed by ilvl. */ + levels?: Record; +} + +/** + * Concrete numbering definition encoded from w:num. + */ +export interface NumberingDefinition { + /** Numbering identifier. */ + numId?: number; + /** Abstract numbering identifier reference. */ + abstractNumId?: number; + /** Level overrides keyed by ilvl. */ + lvlOverrides?: Record; +} + +/** + * Numbering level definition encoded from w:lvl. + */ +export interface NumberingLevel { + /** Level index. */ + ilvl?: number; + /** Template code. */ + tplc?: number; + /** Tentative level flag. */ + tentative?: boolean; + /** Level start value. */ + start?: number; + /** Picture bullet identifier. */ + lvlPicBulletId?: number; + /** Use legal numbering style. */ + isLgl?: boolean; + /** Paragraph style identifier. */ + styleId?: string; + /** Suffix setting. */ + suff?: string; + /** Level text pattern. */ + lvlText?: string; + /** Level justification. */ + lvlJc?: string; + /** Numbering format properties. */ + numFmt?: NumberingFormat; + /** Legacy numbering properties. */ + legacy?: NumberingLegacyProperties; + /** Paragraph properties applied at this level. */ + paragraphProperties?: ParagraphProperties; + /** Run properties applied at this level. */ + runProperties?: RunProperties; +} + +/** + * Numbering level override definition encoded from w:lvlOverride. + */ +export interface NumberingLevelOverride { + /** Level index. */ + ilvl?: number; + /** Start override value. */ + startOverride?: number; + /** Level definition override. */ + lvl?: NumberingLevel; +} + +/** + * Numbering format properties encoded from w:numFmt. + */ +export interface NumberingFormat { + /** Numbering format value. */ + val?: string; + /** Numbering format string. */ + format?: string; +} + +/** + * Legacy numbering properties encoded from w:legacy. + */ +export interface NumberingLegacyProperties { + /** Legacy numbering flag. */ + legacy?: boolean; + /** Legacy spacing value. */ + legacySpace?: number; + /** Legacy indentation value. */ + legacyIndent?: number; +} diff --git a/packages/layout-engine/style-engine/src/ooxml/styles-types.ts b/packages/layout-engine/style-engine/src/ooxml/styles-types.ts new file mode 100644 index 0000000000..9224e99d7e --- /dev/null +++ b/packages/layout-engine/style-engine/src/ooxml/styles-types.ts @@ -0,0 +1,397 @@ +import type { + BorderProperties, + ParagraphConditionalFormatting, + ParagraphProperties, + RunProperties, + ShadingProperties, +} from './types'; + +/** + * Encoded properties for the w:styles document. + */ +export interface StylesDocumentProperties { + /** Default run and paragraph properties for the document. */ + docDefaults: DocDefaults | undefined; + /** Latent style definitions and defaults. */ + latentStyles: LatentStyles; + /** Styles keyed by styleId. */ + styles: Record; +} + +/** + * Default run and paragraph properties stored under w:docDefaults. + */ +export interface DocDefaults { + /** Default run properties. */ + runProperties?: RunProperties; + /** Default paragraph properties. */ + paragraphProperties?: ParagraphProperties; +} + +/** + * Latent style defaults and per-style exceptions. + */ +export interface LatentStyles { + /** Default locked state for latent styles. */ + defLockedState?: boolean; + /** Default UI priority flag for latent styles. */ + defUIPriority?: boolean; + /** Default semi-hidden flag for latent styles. */ + defSemiHidden?: boolean; + /** Default unhide-when-used flag for latent styles. */ + defUnhideWhenUsed?: boolean; + /** Default quick-format flag for latent styles. */ + defQFormat?: boolean; + /** Latent style exceptions keyed by style name. */ + lsdExceptions?: Record; +} + +/** + * Latent style exception definition. + */ +export interface LsdException { + /** Latent style name. */ + name?: string; + /** Locked flag for the latent style. */ + locked?: boolean; + /** Quick format flag for the latent style. */ + qFormat?: boolean; + /** Semi-hidden flag for the latent style. */ + semiHidden?: boolean; + /** Unhide-when-used flag for the latent style. */ + unhideWhenUsed?: boolean; + /** UI priority value for the latent style. */ + uiPriority?: number; +} + +/** + * Encoded style definition from w:style. + */ +export interface StyleDefinition { + /** Style type (paragraph, character, table, etc.). */ + type?: string; + /** Style identifier. */ + styleId?: string; + /** Default style flag. */ + default?: boolean; + /** Custom style flag. */ + customStyle?: boolean; + /** Human-readable style name. */ + name?: string; + /** Comma-separated style aliases. */ + aliases?: string; + /** Based-on style identifier. */ + basedOn?: string; + /** Next style identifier. */ + next?: string; + /** Linked style identifier. */ + link?: string; + /** Auto-redefine flag. */ + autoRedefine?: boolean; + /** Hidden style flag. */ + hidden?: boolean; + /** Semi-hidden style flag. */ + semiHidden?: boolean; + /** Unhide-when-used style flag. */ + unhideWhenUsed?: boolean; + /** Quick-format style flag. */ + qFormat?: boolean; + /** Locked style flag. */ + locked?: boolean; + /** Personal style flag. */ + personal?: boolean; + /** Personal compose flag. */ + personalCompose?: boolean; + /** Personal reply flag. */ + personalReply?: boolean; + /** UI priority value. */ + uiPriority?: number; + /** Revision identifier. */ + rsid?: number; + /** Paragraph properties applied by the style. */ + paragraphProperties?: ParagraphProperties; + /** Run properties applied by the style. */ + runProperties?: RunProperties; + /** Table properties applied by the style. */ + tableProperties?: TableProperties; + /** Table row properties applied by the style. */ + tableRowProperties?: TableRowProperties; + /** Table cell properties applied by the style. */ + tableCellProperties?: TableCellProperties; + /** Table style properties applied by the style. */ + tableStyleProperties?: Record; +} + +/** + * Generic measurement properties used by table layout elements. + */ +export interface MeasurementProperties { + /** Measurement value, usually in twentieths of a point. */ + value?: number; + /** Measurement type (auto, dxa, pct, etc.). */ + type?: string; +} + +/** + * Table properties encoded from w:tblPr. + */ +export interface TableProperties { + /** Right-to-left visual order for table rendering. */ + rightToLeft?: boolean; + /** Table justification value. */ + justification?: string; + /** Table shading properties. */ + shading?: ShadingProperties; + /** Table caption text. */ + caption?: string; + /** Table cell spacing properties. */ + tableCellSpacing?: MeasurementProperties; + /** Table description text. */ + description?: string; + /** Table indent properties. */ + tableIndent?: MeasurementProperties; + /** Table layout algorithm. */ + tableLayout?: string; + /** Table look settings. */ + tblLook?: TableLookProperties; + /** Table overlap behavior. */ + overlap?: string; + /** Table style identifier. */ + tableStyleId?: string; + /** Table style column band size. */ + tableStyleColBandSize?: number; + /** Table style row band size. */ + tableStyleRowBandSize?: number; + /** Table width properties. */ + tableWidth?: MeasurementProperties; + /** Floating table properties. */ + floatingTableProperties?: TableFloatingProperties; + /** Table border properties. */ + borders?: TableBorders; + /** Table cell margin properties. */ + cellMargins?: TableCellMargins; +} + +/** + * Table look properties from w:tblLook. + */ +export interface TableLookProperties { + /** Apply first column formatting. */ + firstColumn?: boolean; + /** Apply first row formatting. */ + firstRow?: boolean; + /** Apply last column formatting. */ + lastColumn?: boolean; + /** Apply last row formatting. */ + lastRow?: boolean; + /** Disable horizontal banding. */ + noHBand?: boolean; + /** Disable vertical banding. */ + noVBand?: boolean; + /** Raw table look value. */ + val?: string; +} + +/** + * Floating table positioning properties. + */ +export interface TableFloatingProperties { + /** Distance from left text boundary. */ + leftFromText?: number; + /** Distance from right text boundary. */ + rightFromText?: number; + /** Distance from top text boundary. */ + topFromText?: number; + /** Distance from bottom text boundary. */ + bottomFromText?: number; + /** Horizontal position value. */ + tblpX?: number; + /** Vertical position value. */ + tblpY?: number; + /** Horizontal anchor reference. */ + horzAnchor?: string; + /** Vertical anchor reference. */ + vertAnchor?: string; + /** Horizontal position specifier. */ + tblpXSpec?: string; + /** Vertical position specifier. */ + tblpYSpec?: string; +} + +/** + * Table border properties. + */ +export interface TableBorders { + /** Bottom border definition. */ + bottom?: BorderProperties; + /** End border definition. */ + end?: BorderProperties; + /** Inside horizontal border definition. */ + insideH?: BorderProperties; + /** Inside vertical border definition. */ + insideV?: BorderProperties; + /** Left border definition. */ + left?: BorderProperties; + /** Right border definition. */ + right?: BorderProperties; + /** Start border definition. */ + start?: BorderProperties; + /** Top border definition. */ + top?: BorderProperties; +} + +/** + * Table cell border properties, including diagonal borders. + */ +export interface TableCellBorders { + /** Top border definition. */ + top?: BorderProperties; + /** Start border definition. */ + start?: BorderProperties; + /** Left border definition. */ + left?: BorderProperties; + /** Bottom border definition. */ + bottom?: BorderProperties; + /** End border definition. */ + end?: BorderProperties; + /** Right border definition. */ + right?: BorderProperties; + /** Inside horizontal border definition. */ + insideH?: BorderProperties; + /** Inside vertical border definition. */ + insideV?: BorderProperties; + /** Top-left to bottom-right diagonal border definition. */ + tl2br?: BorderProperties; + /** Top-right to bottom-left diagonal border definition. */ + tr2bl?: BorderProperties; +} + +/** + * Table cell margin properties. + */ +export interface TableCellMargins { + /** Bottom margin properties. */ + marginBottom?: MeasurementProperties; + /** End margin properties. */ + marginEnd?: MeasurementProperties; + /** Left margin properties. */ + marginLeft?: MeasurementProperties; + /** Right margin properties. */ + marginRight?: MeasurementProperties; + /** Start margin properties. */ + marginStart?: MeasurementProperties; + /** Top margin properties. */ + marginTop?: MeasurementProperties; +} + +/** + * Table row properties encoded from w:trPr. + */ +export interface TableRowProperties { + /** Prevent row from splitting across pages. */ + cantSplit: boolean; + /** Conditional formatting properties. */ + cnfStyle?: ParagraphConditionalFormatting; + /** Division identifier value. */ + divId?: string; + /** Grid cells after the row. */ + gridAfter?: number; + /** Grid cells before the row. */ + gridBefore?: number; + /** Hide the row. */ + hidden: boolean; + /** Row justification value. */ + justification?: string; + /** Table cell spacing properties. */ + tableCellSpacing?: MeasurementProperties; + /** Repeat header row flag. */ + repeatHeader: boolean; + /** Row height properties. */ + rowHeight?: TableRowHeight; + /** Width after the row. */ + wAfter?: MeasurementProperties; + /** Width before the row. */ + wBefore?: MeasurementProperties; +} + +/** + * Table row height properties. + */ +export interface TableRowHeight { + /** Row height value. */ + value?: number; + /** Row height rule. */ + rule?: string; +} + +/** + * Table cell properties encoded from w:tcPr. + */ +export interface TableCellProperties { + /** Conditional formatting properties. */ + cnfStyle?: ParagraphConditionalFormatting; + /** Table cell width properties. */ + cellWidth?: MeasurementProperties; + /** Horizontal grid span for the cell. */ + gridSpan?: number; + /** Vertical merge behavior. */ + vMerge?: string; + /** Table cell border properties. */ + borders?: TableCellBorders; + /** Table cell shading properties. */ + shading?: ShadingProperties; + /** Disable wrapping within the cell. */ + noWrap?: boolean; + /** Table cell margin properties. */ + cellMargins?: TableCellMargins; + /** Text direction within the cell. */ + textDirection?: string; + /** Fit text within the cell. */ + tcFitText?: boolean; + /** Vertical alignment within the cell. */ + vAlign?: string; + /** Hide the cell mark. */ + hideMark?: boolean; + /** Header references applied to the cell. */ + headers?: TableHeaderReference[]; +} + +/** + * Header reference entry encoded from w:headers. + */ +export interface TableHeaderReference { + /** Header value. */ + header: string; +} + +export type TableStyleType = + | 'wholeTable' + | 'firstRow' + | 'lastRow' + | 'firstCol' + | 'lastCol' + | 'band1Vert' + | 'band2Vert' + | 'band1Horz' + | 'band2Horz' + | 'neCell' + | 'nwCell' + | 'seCell' + | 'swCell'; +/** + * Table style properties encoded from w:tblStylePr. + */ +export interface TableStyleProperties { + /** Table style property type. */ + type?: TableStyleType; + /** Paragraph properties for the table style. */ + paragraphProperties?: ParagraphProperties; + /** Run properties for the table style. */ + runProperties?: RunProperties; + /** Table properties for the table style. */ + tableProperties?: TableProperties; + /** Table row properties for the table style. */ + tableRowProperties?: TableRowProperties; + /** Table cell properties for the table style. */ + tableCellProperties?: TableCellProperties; +} diff --git a/packages/layout-engine/style-engine/src/ooxml/types.ts b/packages/layout-engine/style-engine/src/ooxml/types.ts new file mode 100644 index 0000000000..f97d934a4c --- /dev/null +++ b/packages/layout-engine/style-engine/src/ooxml/types.ts @@ -0,0 +1,475 @@ +/** + * Paragraph properties encoded from the w:pPr translator. + */ +export interface ParagraphProperties { + /** Adjust right indent to avoid running into a right-aligned object. */ + adjustRightInd?: boolean; + /** Automatically add spacing between East Asian and Latin text. */ + autoSpaceDE?: boolean; + /** Automatically add spacing between East Asian and digits. */ + autoSpaceDN?: boolean; + /** Conditional formatting settings for the paragraph. */ + cnfStyle?: ParagraphConditionalFormatting; + /** Use contextual spacing rules for this paragraph. */ + contextualSpacing?: boolean; + /** Division identifier value. */ + divId?: string; + /** Frame properties for the paragraph. */ + framePr?: ParagraphFrameProperties; + /** Indentation properties for the paragraph. */ + indent?: ParagraphIndentation; + /** Paragraph justification value. */ + justification?: string; + /** Keep all lines of this paragraph on the same page. */ + keepLines?: boolean; + /** Keep this paragraph with the next paragraph. */ + keepNext?: boolean; + /** Kinsoku (line breaking) behavior flag. */ + kinsoku?: boolean; + /** Mirror indents for facing pages. */ + mirrorIndents?: boolean; + /** Numbering properties for the paragraph. */ + numberingProperties?: ParagraphNumberingProperties; + /** Outline level for the paragraph. */ + outlineLvl?: number; + /** Allow punctuation to hang in the right margin. */ + overflowPunct?: boolean; + /** Paragraph border definitions. */ + borders?: ParagraphBorders; + /** Paragraph style identifier. */ + styleId?: string; + /** Insert a page break before the paragraph. */ + pageBreakBefore?: boolean; + /** Paragraph shading properties. */ + shading?: ShadingProperties; + /** Snap paragraph line spacing to document grid. */ + snapToGrid?: boolean; + /** Paragraph spacing properties. */ + spacing?: ParagraphSpacing; + /** Suppress automatic hyphenation. */ + suppressAutoHyphens?: boolean; + /** Suppress line numbering for the paragraph. */ + suppressLineNumbers?: boolean; + /** Suppress overlapping of paragraph text with other objects. */ + suppressOverlap?: boolean; + /** Tab stop definitions for the paragraph. */ + tabStops?: ParagraphTabStop[]; + /** Baseline text alignment within the line. */ + textAlignment?: 'top' | 'center' | 'baseline' | 'bottom' | 'auto'; + /** Text direction for the paragraph. */ + textDirection?: string; + /** Textbox tight wrap setting for the paragraph. */ + textboxTightWrap?: string; + /** Use top line punctuation for the paragraph. */ + topLinePunct?: boolean; + /** Widow/orphan control flag. */ + widowControl?: boolean; + /** Enable word wrapping for the paragraph. */ + wordWrap?: boolean; + /** Run properties applied to the paragraph. */ + runProperties?: RunProperties; + /** Right-to-left paragraph direction flag. */ + rightToLeft?: boolean; +} + +/** + * Conditional formatting properties for a paragraph. + */ +export interface ParagraphConditionalFormatting { + /** Even horizontal band flag. */ + evenHBand?: boolean; + /** Even vertical band flag. */ + evenVBand?: boolean; + /** First column flag. */ + firstColumn?: boolean; + /** First row flag. */ + firstRow?: boolean; + /** First row first column flag. */ + firstRowFirstColumn?: boolean; + /** First row last column flag. */ + firstRowLastColumn?: boolean; + /** Last column flag. */ + lastColumn?: boolean; + /** Last row flag. */ + lastRow?: boolean; + /** Last row first column flag. */ + lastRowFirstColumn?: boolean; + /** Last row last column flag. */ + lastRowLastColumn?: boolean; + /** Odd horizontal band flag. */ + oddHBand?: boolean; + /** Odd vertical band flag. */ + oddVBand?: boolean; + /** Raw conditional formatting value attribute. */ + val?: string; +} + +/** + * Paragraph frame properties. + */ +export interface ParagraphFrameProperties { + /** Lock the anchor for the frame. */ + anchorLock?: boolean; + /** Drop cap style. */ + dropCap?: string; + /** Frame height in twentieths of a point. */ + h?: number; + /** Horizontal anchor type. */ + hAnchor?: string; + /** Height rule for the frame. */ + hRule?: string; + /** Horizontal space around the frame. */ + hSpace?: number; + /** Number of text lines in the frame. */ + lines?: number; + /** Vertical anchor type. */ + vAnchor?: string; + /** Vertical space around the frame. */ + vSpace?: number; + /** Frame width in twentieths of a point. */ + w?: number; + /** Wrapping style for the frame. */ + wrap?: 'auto' | 'notBeside' | 'around' | 'tight' | 'through' | 'none'; + /** Horizontal position. */ + x?: number; + /** Horizontal alignment. */ + xAlign?: string; + /** Vertical position. */ + y?: number; + /** Vertical alignment. */ + yAlign?: string; +} + +/** + * Paragraph indentation properties. + */ +export interface ParagraphIndentation { + /** End indent in twentieths of a point. */ + end?: number; + /** End indent in character units. */ + endChars?: number; + /** First line indent in twentieths of a point. */ + firstLine?: number; + /** First line indent in character units. */ + firstLineChars?: number; + /** Hanging indent in twentieths of a point. */ + hanging?: number; + /** Hanging indent in character units. */ + hangingChars?: number; + /** Left indent in twentieths of a point. */ + left?: number; + /** Left indent in character units. */ + leftChars?: number; + /** Right indent in twentieths of a point. */ + right?: number; + /** Right indent in character units. */ + rightChars?: number; + /** Start indent in twentieths of a point. */ + start?: number; + /** Start indent in character units. */ + startChars?: number; +} + +/** + * Paragraph spacing properties. + */ +export interface ParagraphSpacing { + /** Space after the paragraph in twentieths of a point. */ + after?: number; + /** Auto spacing after the paragraph. */ + afterAutospacing?: boolean; + /** Space after the paragraph in line units. */ + afterLines?: number; + /** Space before the paragraph in twentieths of a point. */ + before?: number; + /** Auto spacing before the paragraph. */ + beforeAutospacing?: boolean; + /** Space before the paragraph in line units. */ + beforeLines?: number; + /** Line spacing value in twentieths of a point. */ + line?: number; + /** Line spacing rule. */ + lineRule?: string; +} + +/** + * Paragraph numbering properties. + */ +export interface ParagraphNumberingProperties { + /** Numbering level. */ + ilvl?: number; + /** Numbering ID. */ + numId?: number; +} + +/** + * Paragraph border collection for each side. + */ +export interface ParagraphBorders { + /** Bar border definition. */ + bar?: BorderProperties; + /** Between border definition. */ + between?: BorderProperties; + /** Bottom border definition. */ + bottom?: BorderProperties; + /** Left border definition. */ + left?: BorderProperties; + /** Right border definition. */ + right?: BorderProperties; + /** Top border definition. */ + top?: BorderProperties; +} + +/** + * Generic border properties used by paragraph and run borders. + */ +export interface BorderProperties { + /** Border style value. */ + val?: string; + /** Border color, including "auto" or a hex color string. */ + color?: string; + /** Theme color reference. */ + themeColor?: string; + /** Theme tint value. */ + themeTint?: string; + /** Theme shade value. */ + themeShade?: string; + /** Border size in eighths of a point. */ + size?: number; + /** Border spacing in points. */ + space?: number; + /** Border shadow flag. */ + shadow?: boolean; + /** Border frame flag. */ + frame?: boolean; +} + +/** + * Shading properties shared by paragraph and run shading. + */ +export interface ShadingProperties { + /** Foreground color. */ + color?: string; + /** Background fill color. */ + fill?: string; + /** Theme foreground color. */ + themeColor?: string; + /** Theme fill color. */ + themeFill?: string; + /** Theme fill shade. */ + themeFillShade?: string; + /** Theme fill tint. */ + themeFillTint?: string; + /** Theme shade value. */ + themeShade?: string; + /** Theme tint value. */ + themeTint?: string; + /** Shading pattern value. */ + val?: string; +} + +/** + * A single tab stop entry in a paragraph. + */ +export interface ParagraphTabStop { + /** Tab stop attributes keyed under the tab name. */ + tab: TabStopProperties; +} + +/** + * Tab stop properties. + */ +export interface TabStopProperties { + /** Tab alignment type. */ + tabType?: string; + /** Tab position in twentieths of a point. */ + pos?: number; + /** Tab leader type. */ + leader?: string; +} + +/** + * Run properties. + */ +export interface RunProperties { + /** Bold formatting flag for complex script. */ + boldCs?: boolean; + /** Bold formatting flag. */ + bold?: boolean; + /** Run border properties. */ + borders?: BorderProperties; + /** Text transform value set by caps. */ + textTransform?: 'uppercase' | 'none'; + /** Run color properties. */ + color?: RunColorProperties; + /** Complex script formatting flag. */ + cs?: boolean; + /** Double strikethrough flag. */ + dstrike?: boolean; + /** East Asian layout properties. */ + eastAsianLayout?: RunEastAsianLayoutProperties; + /** Emphasis mark type. */ + effect?: string; + /** Emphasis mark setting. */ + em?: string; + /** Emboss effect flag. */ + emboss?: boolean; + /** Fit text properties. */ + fitText?: RunFitTextProperties; + /** Font family attributes. */ + fontFamily?: RunFontFamilyProperties; + /** Complex script font size in half-points. */ + fontSizeCs?: number; + /** Font size in half-points. */ + fontSize?: number; + /** Highlight properties. */ + highlight?: HighlightProperties; + /** Imprint effect flag. */ + imprint?: boolean; + /** Italic formatting flag. */ + italic?: boolean; + /** Italic formatting flag for complex script. */ + iCs?: boolean; + /** Kerning value in half-points. */ + kern?: number; + /** Language properties. */ + lang?: RunLangProperties; + /** Letter spacing in twentieths of a point. */ + letterSpacing?: number; + /** Disable proofing flag. */ + noProof?: boolean; + /** Office math flag. */ + oMath?: boolean; + /** Outline effect flag. */ + outline?: boolean; + /** Baseline position adjustment. */ + position?: number; + /** Right-to-left run flag. */ + rtl?: boolean; + /** Run style identifier. */ + styleId?: string; + /** Shadow effect flag. */ + shadow?: boolean; + /** Run shading properties. */ + shading?: ShadingProperties; + /** Small caps flag. */ + smallCaps?: boolean; + /** Snap run spacing to document grid. */ + snapToGrid?: boolean; + /** SpecVanish flag. */ + specVanish?: boolean; + /** Strikethrough flag. */ + strike?: boolean; + /** Underline properties. */ + underline?: UnderlineProperties; + /** Hidden text flag. */ + vanish?: boolean; + /** Vertical alignment setting. */ + vertAlign?: string; + /** Web hidden flag. */ + webHidden?: boolean; + /** Character width setting. */ + w?: string; +} + +/** + * Run color properties. + */ +export interface RunColorProperties { + /** Direct color value. */ + val?: string; + /** Theme color reference. */ + themeColor?: string; + /** Theme tint value. */ + themeTint?: string; + /** Theme shade value. */ + themeShade?: string; +} + +/** + * Run font family properties. + */ +export interface RunFontFamilyProperties { + /** Font hint value. */ + hint?: string; + /** ASCII font name. */ + ascii?: string; + /** High ANSI font name. */ + hAnsi?: string; + /** East Asian font name. */ + eastAsia?: string; + /** Complex script font name. */ + cs?: string; + /** Font name alias. */ + val?: string; + /** ASCII theme font. */ + asciiTheme?: string; + /** High ANSI theme font. */ + hAnsiTheme?: string; + /** East Asian theme font. */ + eastAsiaTheme?: string; + /** Complex script theme font. */ + cstheme?: string; +} + +/** + * East Asian layout properties for a run. + */ +export interface RunEastAsianLayoutProperties { + /** East Asian layout ID. */ + id?: number; + /** Combine characters flag. */ + combine?: boolean; + /** Combine brackets rule. */ + combineBrackets?: string; + /** Vertical text flag. */ + vert?: boolean; + /** Vertical compression flag. */ + vertCompress?: boolean; +} + +/** + * Fit text properties for a run. + */ +export interface RunFitTextProperties { + /** Fit text value. */ + val?: number; + /** Fit text ID. */ + id?: number; +} + +/** + * Language properties for a run. + */ +export interface RunLangProperties { + /** Default language value. */ + val?: string; + /** East Asian language value. */ + eastAsia?: string; + /** Bidi language value. */ + bidi?: string; +} + +/** + * Underline properties encoded as raw OOXML attributes. + */ +export interface UnderlineProperties { + /** Underline type attribute. */ + 'w:val'?: string | null; + /** Underline color attribute. */ + 'w:color'?: string; + /** Underline theme color attribute. */ + 'w:themeColor'?: string; + /** Underline theme tint attribute. */ + 'w:themeTint'?: string; + /** Underline theme shade attribute. */ + 'w:themeShade'?: string; +} + +/** + * Highlight properties encoded as raw OOXML attributes. + */ +export interface HighlightProperties { + /** Highlight value attribute. */ + 'w:val'?: string | null; +} diff --git a/packages/layout-engine/tests/src/multi-section-page-count-simple.test.ts b/packages/layout-engine/tests/src/multi-section-page-count-simple.test.ts index c805af9e4f..aae464740a 100644 --- a/packages/layout-engine/tests/src/multi-section-page-count-simple.test.ts +++ b/packages/layout-engine/tests/src/multi-section-page-count-simple.test.ts @@ -22,6 +22,7 @@ import type { FlowBlock, PMNode, SectionBreakBlock, Measure } from '@superdoc/co import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'node:url'; +import { DEFAULT_CONVERTER_CONTEXT } from './test-helpers/section-test-utils.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -99,6 +100,7 @@ describe('Multi-Section Document Page Count (Simple)', () => { console.log('\nConverting to flow blocks...'); const { blocks, bookmarks } = toFlowBlocks(pmDoc, { emitSectionBreaks: true, // CRITICAL: Must enable section break emission + converterContext: DEFAULT_CONVERTER_CONTEXT, }); console.log(`Generated ${blocks.length} flow blocks`); console.log(`Generated ${bookmarks.size} bookmarks`); @@ -203,7 +205,10 @@ describe('Multi-Section Document Page Count (Simple)', () => { it('should emit 4 section break blocks for a 4-section document', () => { const pmDoc = loadPMJsonFixture('multi_section_doc.json'); - const { blocks } = toFlowBlocks(pmDoc, { emitSectionBreaks: true }); + const { blocks } = toFlowBlocks(pmDoc, { + emitSectionBreaks: true, + converterContext: DEFAULT_CONVERTER_CONTEXT, + }); const sectionBreaks = blocks.filter((b) => b.kind === 'sectionBreak'); console.log(`\nSection breaks found: ${sectionBreaks.length}`); @@ -224,7 +229,10 @@ describe('Multi-Section Document Page Count (Simple)', () => { it('should have correct section break types', () => { const pmDoc = loadPMJsonFixture('multi_section_doc.json'); - const { blocks } = toFlowBlocks(pmDoc, { emitSectionBreaks: true }); + const { blocks } = toFlowBlocks(pmDoc, { + emitSectionBreaks: true, + converterContext: DEFAULT_CONVERTER_CONTEXT, + }); const sectionBreaks = blocks.filter((b) => b.kind === 'sectionBreak') as SectionBreakBlock[]; diff --git a/packages/layout-engine/tests/src/multi-section-page-count.test.ts b/packages/layout-engine/tests/src/multi-section-page-count.test.ts index 818cfd62c5..3cf287fa3f 100644 --- a/packages/layout-engine/tests/src/multi-section-page-count.test.ts +++ b/packages/layout-engine/tests/src/multi-section-page-count.test.ts @@ -31,9 +31,17 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); * This uses the same machinery as the extract-pm-json script. * * @param docxPath - Path to DOCX file - * @returns ProseMirror document + * @returns ProseMirror document and converter context */ -async function docxToPMJson(docxPath: string): Promise { +async function docxToPMJson(docxPath: string): Promise<{ + pmDoc: PMNode; + converterContext: { + docx: Record; + translatedLinkedStyles: unknown; + translatedNumbering: unknown; + }; + themeColors?: unknown; +}> { // Dynamic imports to avoid bundling issues const { default: DocxZipper } = await import('../../../super-editor/src/core/DocxZipper.js'); const { createDocumentJson } = await import( @@ -76,7 +84,15 @@ async function docxToPMJson(docxPath: string): Promise { throw new Error('Failed to extract PM JSON from DOCX'); } - return result.pmDoc; + return { + pmDoc: result.pmDoc, + converterContext: { + docx, + translatedLinkedStyles: result.translatedLinkedStyles, + translatedNumbering: result.translatedNumbering, + }, + themeColors: result.themeColors, + }; } /** @@ -133,12 +149,16 @@ describe('Multi-Section Document Page Count', () => { // Convert DOCX to PM JSON console.log('Converting DOCX to ProseMirror JSON...'); - const pmDoc = await docxToPMJson(docxPath); + const { pmDoc, converterContext, themeColors } = await docxToPMJson(docxPath); console.log(`PM Doc has ${pmDoc.content?.length ?? 0} top-level nodes`); // Convert PM JSON to flow blocks console.log('Converting to flow blocks...'); - const { blocks, bookmarks } = toFlowBlocks(pmDoc, { emitSectionBreaks: true }); + const { blocks, bookmarks } = toFlowBlocks(pmDoc, { + emitSectionBreaks: true, + converterContext, + themeColors, + }); console.log(`Generated ${blocks.length} flow blocks`); // Analyze section breaks @@ -196,8 +216,12 @@ describe('Multi-Section Document Page Count', () => { it('should emit 3 section break blocks for a 4-section document', async () => { const docxPath = path.join(__dirname, '../../../super-editor/src/tests/data/multi_section_doc.docx'); - const pmDoc = await docxToPMJson(docxPath); - const { blocks } = toFlowBlocks(pmDoc, { emitSectionBreaks: true }); + const { pmDoc, converterContext, themeColors } = await docxToPMJson(docxPath); + const { blocks } = toFlowBlocks(pmDoc, { + emitSectionBreaks: true, + converterContext, + themeColors, + }); const sectionBreaks = blocks.filter((b) => b.kind === 'sectionBreak'); console.log(`Section breaks found: ${sectionBreaks.length}`); @@ -219,8 +243,12 @@ describe('Multi-Section Document Page Count', () => { it('should have correct section break types', async () => { const docxPath = path.join(__dirname, '../../../super-editor/src/tests/data/multi_section_doc.docx'); - const pmDoc = await docxToPMJson(docxPath); - const { blocks } = toFlowBlocks(pmDoc, { emitSectionBreaks: true }); + const { pmDoc, converterContext, themeColors } = await docxToPMJson(docxPath); + const { blocks } = toFlowBlocks(pmDoc, { + emitSectionBreaks: true, + converterContext, + themeColors, + }); const sectionBreaks = blocks.filter((b) => b.kind === 'sectionBreak') as SectionBreakBlock[]; diff --git a/packages/layout-engine/tests/src/sd-1495-auto-page-break.test.ts b/packages/layout-engine/tests/src/sd-1495-auto-page-break.test.ts index 6d6d627992..4aef622f1f 100644 --- a/packages/layout-engine/tests/src/sd-1495-auto-page-break.test.ts +++ b/packages/layout-engine/tests/src/sd-1495-auto-page-break.test.ts @@ -71,6 +71,8 @@ async function loadDocxFixture(filename: string): Promise<{ pmDoc: PMNode; conve docx, numbering: result.numbering, linkedStyles: result.linkedStyles, + translatedLinkedStyles: result.translatedLinkedStyles, + translatedNumbering: result.translatedNumbering, }; return { pmDoc: result.pmDoc, converterContext }; diff --git a/packages/layout-engine/tests/src/section-refs-merging.test.ts b/packages/layout-engine/tests/src/section-refs-merging.test.ts index 646b9f9b85..83f1b67625 100644 --- a/packages/layout-engine/tests/src/section-refs-merging.test.ts +++ b/packages/layout-engine/tests/src/section-refs-merging.test.ts @@ -16,7 +16,7 @@ import type { PMNode, FlowBlock, SectionBreakBlock } from '@superdoc/contracts'; import { toFlowBlocks } from '@superdoc/pm-adapter'; import { layoutDocument } from '@superdoc/layout-engine'; import { measureBlock } from '@superdoc/measuring-dom'; -import { resetBlockIdCounter, PAGE_SIZES } from './test-helpers/section-test-utils.js'; +import { DEFAULT_CONVERTER_CONTEXT, resetBlockIdCounter, PAGE_SIZES } from './test-helpers/section-test-utils.js'; /** * Header/footer ref types for testing. @@ -233,7 +233,7 @@ function createPMDocWithSectionRefs( * Convert PM doc to flow blocks with section breaks enabled. */ function pmToFlowBlocks(pmDoc: PMNode): { blocks: FlowBlock[]; bookmarks: Map } { - return toFlowBlocks(pmDoc, { emitSectionBreaks: true }); + return toFlowBlocks(pmDoc, { emitSectionBreaks: true, converterContext: DEFAULT_CONVERTER_CONTEXT }); } /** diff --git a/packages/layout-engine/tests/src/test-helpers/section-test-utils.ts b/packages/layout-engine/tests/src/test-helpers/section-test-utils.ts index 6fc59b9e66..12be635f46 100644 --- a/packages/layout-engine/tests/src/test-helpers/section-test-utils.ts +++ b/packages/layout-engine/tests/src/test-helpers/section-test-utils.ts @@ -11,6 +11,7 @@ import type { PMNode, FlowBlock, SectionBreakBlock, Measure, Layout, Page } from import { toFlowBlocks } from '@superdoc/pm-adapter'; import { layoutDocument } from '@superdoc/layout-engine'; import { measureBlock } from '@superdoc/measuring-dom'; +import type { NumberingProperties, StylesDocumentProperties } from '@superdoc/style-engine/ooxml'; /** * Section properties for creating test documents. @@ -45,6 +46,22 @@ export const DEFAULT_MARGINS = { footer: 72, } as const; +const DEFAULT_TRANSLATED_LINKED_STYLES: StylesDocumentProperties = { + docDefaults: {}, + latentStyles: {}, + styles: {}, +}; + +const DEFAULT_TRANSLATED_NUMBERING: NumberingProperties = { + abstracts: {}, + definitions: {}, +}; + +export const DEFAULT_CONVERTER_CONTEXT = { + translatedLinkedStyles: DEFAULT_TRANSLATED_LINKED_STYLES, + translatedNumbering: DEFAULT_TRANSLATED_NUMBERING, +}; + /** * Counter for generating unique block IDs. */ @@ -360,6 +377,7 @@ export function pmToFlowBlocks(pmDoc: PMNode): { } { return toFlowBlocks(pmDoc, { emitSectionBreaks: true, + converterContext: DEFAULT_CONVERTER_CONTEXT, }); } diff --git a/packages/super-editor/src/core/Editor.lifecycle.test.ts b/packages/super-editor/src/core/Editor.lifecycle.test.ts index 51ddcf4981..36338db571 100644 --- a/packages/super-editor/src/core/Editor.lifecycle.test.ts +++ b/packages/super-editor/src/core/Editor.lifecycle.test.ts @@ -6,8 +6,9 @@ import { FileSystemNotAvailableError, DocumentLoadError, } from './errors/index.js'; -import { loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; +import { loadTestDataForEditorTests, getMinimalTranslatedLinkedStyles } from '@tests/helpers/helpers.js'; import { getStarterExtensions } from '@extensions/index.js'; +import { SuperConverter } from './super-converter/SuperConverter.js'; /** * Comprehensive test suite for the Editor Document Lifecycle API. @@ -303,10 +304,14 @@ describe('Editor Lifecycle API', () => { }); it('should allow overriding mode', async () => { + const converter = new SuperConverter(); + converter.translatedLinkedStyles = getMinimalTranslatedLinkedStyles(); + editor = await Editor.open(undefined, { extensions: getStarterExtensions(), suppressDefaultDocxStyles: true, mode: 'html', + converter, }); expect(editor.options.mode).toBe('html'); diff --git a/packages/super-editor/src/core/header-footer/HeaderFooterRegistry.ts b/packages/super-editor/src/core/header-footer/HeaderFooterRegistry.ts index c6c80475b0..bf998101e1 100644 --- a/packages/super-editor/src/core/header-footer/HeaderFooterRegistry.ts +++ b/packages/super-editor/src/core/header-footer/HeaderFooterRegistry.ts @@ -6,6 +6,7 @@ import type { Editor } from '@core/Editor.js'; import { EventEmitter } from '@core/EventEmitter.js'; import { createHeaderFooterEditor, onHeaderFooterDataUpdate } from '@extensions/pagination/pagination-helpers.js'; import { updateYdocDocxData } from '@extensions/collaboration/collaboration-helpers.js'; +import type { ConverterContext } from '@superdoc/pm-adapter/converter-context.js'; const HEADER_FOOTER_VARIANTS = ['default', 'first', 'even', 'odd'] as const; const DEFAULT_HEADER_FOOTER_HEIGHT = 100; @@ -1173,7 +1174,7 @@ export class HeaderFooterLayoutAdapter { * * @returns The converter context containing document metadata, or undefined if not available */ - #getConverterContext(): MinimalConverterContext | undefined { + #getConverterContext(): ConverterContext | undefined { const rootEditor = this.#manager.rootEditor; if (!('converter' in rootEditor)) { return undefined; @@ -1181,23 +1182,13 @@ export class HeaderFooterLayoutAdapter { const converter = (rootEditor as EditorWithConverter).converter as Record | undefined; if (!converter) return undefined; - const context: MinimalConverterContext = {}; + const context: ConverterContext = { + docx: converter.convertedXml, + numbering: converter.numbering, + translatedLinkedStyles: converter.translatedLinkedStyles, + translatedNumbering: converter.translatedNumbering, + } as ConverterContext; - // Type guard: check if convertedXml exists and is a record - if (converter.convertedXml && typeof converter.convertedXml === 'object') { - context.docx = converter.convertedXml as Record; - } - - // Type guard: check if numbering exists and has expected structure - if (converter.numbering && typeof converter.numbering === 'object') { - context.numbering = converter.numbering as MinimalConverterContext['numbering']; - } - - // Type guard: check if linkedStyles exists and is an array - if (Array.isArray(converter.linkedStyles)) { - context.linkedStyles = converter.linkedStyles as MinimalConverterContext['linkedStyles']; - } - - return Object.keys(context).length > 0 ? context : undefined; + return context; } } diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 69815ff6ed..8b88df9e15 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -2774,6 +2774,8 @@ export class PresentationEditor extends EventEmitter { numbering: converter.numbering, linkedStyles: converter.linkedStyles, ...(Object.keys(footnoteNumberById).length ? { footnoteNumberById } : {}), + translatedLinkedStyles: converter.translatedLinkedStyles, + translatedNumbering: converter.translatedNumbering, } : undefined; const atomNodeTypes = getAtomNodeTypesFromSchema(this.#editor?.schema ?? null); diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.js b/packages/super-editor/src/core/super-converter/SuperConverter.js index 81da0e830f..6863be9330 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/core/super-converter/SuperConverter.js @@ -197,9 +197,12 @@ class SuperConverter { this.xml = params?.xml; this.declaration = null; - // List defs + // List defs (deprecated) this.numbering = {}; + // Translated numbering definitions + this.translatedNumbering = {}; + // Processed additional content this.numbering = null; this.pageStyles = null; @@ -219,9 +222,12 @@ class SuperConverter { this.importedBodyHasFooterRef = false; this.headerFooterModified = false; - // Linked Styles + // Linked Styles (deprecated) this.linkedStyles = []; + // Translated linked styles + this.translatedLinkedStyles = {}; + // This is the JSON schema that we will be working with this.json = params?.json; @@ -928,6 +934,8 @@ class SuperConverter { this.comments = result.comments; this.footnotes = result.footnotes; this.linkedStyles = result.linkedStyles; + this.translatedLinkedStyles = result.translatedLinkedStyles; + this.translatedNumbering = result.translatedNumbering; this.inlineDocumentFonts = result.inlineDocumentFonts; this.themeColors = result.themeColors ?? null; diff --git a/packages/super-editor/src/core/super-converter/styles-font-priority.test.js b/packages/super-editor/src/core/super-converter/styles-font-priority.test.js index 01a56a1fbf..4770bda836 100644 --- a/packages/super-editor/src/core/super-converter/styles-font-priority.test.js +++ b/packages/super-editor/src/core/super-converter/styles-font-priority.test.js @@ -9,7 +9,7 @@ * precedence according to the OOXML specification. */ import { describe, it, expect, vi, beforeAll } from 'vitest'; -import { resolveRunProperties } from './styles.js'; +import { resolveRunProperties, combineRunProperties } from './styles.js'; beforeAll(() => { vi.stubGlobal('SuperConverter', { @@ -17,6 +17,33 @@ beforeAll(() => { }); }); +const buildTranslatedLinkedStyles = (styles = {}) => ({ + docDefaults: { + runProperties: {}, + paragraphProperties: {}, + }, + latentStyles: {}, + styles: { + Normal: { + styleId: 'Normal', + type: 'paragraph', + default: true, + name: 'Normal', + runProperties: {}, + paragraphProperties: {}, + }, + DefaultParagraphFont: { + styleId: 'DefaultParagraphFont', + type: 'character', + default: true, + name: 'Default Paragraph Font', + runProperties: {}, + paragraphProperties: {}, + }, + ...styles, + }, +}); + describe('resolveRunProperties - inline property priority', () => { /** * Test that inline w:sz (direct formatting) overrides character style fontSize. @@ -27,71 +54,30 @@ describe('resolveRunProperties - inline property priority', () => { * - Expected: fontSize should be 24 (12pt from inline), not 18 (from style) */ it('should prioritize inline fontSize over character style fontSize', () => { - // Mock params with styles - const params = { - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - // p1 paragraph style with rPr - { - name: 'w:style', - attributes: { 'w:type': 'paragraph', 'w:styleId': 'p1' }, - elements: [ - { - name: 'w:basedOn', - attributes: { 'w:val': 'Normal' }, - }, - { - name: 'w:rPr', - elements: [ - { - name: 'w:sz', - attributes: { 'w:val': '18' }, // 9pt in paragraph style - }, - ], - }, - ], - }, - // s1 character style - { - name: 'w:style', - attributes: { 'w:type': 'character', 'w:styleId': 's1' }, - elements: [ - { - name: 'w:basedOn', - attributes: { 'w:val': 'DefaultParagraphFont' }, - }, - { - name: 'w:rPr', - elements: [ - { - name: 'w:sz', - attributes: { 'w:val': '18' }, // 9pt in character style - }, - ], - }, - ], - }, - // Normal style - { - name: 'w:style', - attributes: { 'w:type': 'paragraph', 'w:styleId': 'Normal', 'w:default': '1' }, - elements: [], - }, - // DefaultParagraphFont style - { - name: 'w:style', - attributes: { 'w:type': 'character', 'w:styleId': 'DefaultParagraphFont', 'w:default': '1' }, - elements: [], - }, - ], - }, - ], + const translatedLinkedStyles = buildTranslatedLinkedStyles({ + p1: { + styleId: 'p1', + type: 'paragraph', + basedOn: 'Normal', + runProperties: { + fontSize: 18, }, + paragraphProperties: {}, }, - numbering: { definitions: {}, abstracts: {} }, + s1: { + styleId: 's1', + type: 'character', + basedOn: 'DefaultParagraphFont', + runProperties: { + fontSize: 18, + }, + paragraphProperties: {}, + }, + }); + + const params = { + translatedLinkedStyles, + translatedNumbering: { definitions: {}, abstracts: {} }, }; // Inline run properties: has BOTH styleId (s1) AND inline fontSize (24) @@ -115,46 +101,18 @@ describe('resolveRunProperties - inline property priority', () => { it('should use character style fontSize when no inline fontSize is specified', () => { const params = { - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:style', - attributes: { 'w:type': 'character', 'w:styleId': 's1' }, - elements: [ - { - name: 'w:basedOn', - attributes: { 'w:val': 'DefaultParagraphFont' }, - }, - { - name: 'w:rPr', - elements: [ - { - name: 'w:sz', - attributes: { 'w:val': '18' }, - }, - ], - }, - ], - }, - { - name: 'w:style', - attributes: { 'w:type': 'paragraph', 'w:styleId': 'Normal', 'w:default': '1' }, - elements: [], - }, - { - name: 'w:style', - attributes: { 'w:type': 'character', 'w:styleId': 'DefaultParagraphFont', 'w:default': '1' }, - elements: [], - }, - ], - }, - ], + translatedLinkedStyles: buildTranslatedLinkedStyles({ + s1: { + styleId: 's1', + type: 'character', + basedOn: 'DefaultParagraphFont', + runProperties: { + fontSize: 18, + }, + paragraphProperties: {}, }, - }, - numbering: { definitions: {}, abstracts: {} }, + }), + translatedNumbering: { definitions: {}, abstracts: {} }, }; // Inline run properties: ONLY has styleId, NO inline fontSize @@ -175,50 +133,19 @@ describe('resolveRunProperties - inline property priority', () => { it('should handle hyperlink character style with color and underline', () => { const params = { - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:style', - attributes: { 'w:type': 'character', 'w:styleId': 'Hyperlink' }, - elements: [ - { - name: 'w:basedOn', - attributes: { 'w:val': 'DefaultParagraphFont' }, - }, - { - name: 'w:rPr', - elements: [ - { - name: 'w:color', - attributes: { 'w:val': '0000FF' }, // Blue - }, - { - name: 'w:u', - attributes: { 'w:val': 'single' }, // Underline - }, - ], - }, - ], - }, - { - name: 'w:style', - attributes: { 'w:type': 'paragraph', 'w:styleId': 'Normal', 'w:default': '1' }, - elements: [], - }, - { - name: 'w:style', - attributes: { 'w:type': 'character', 'w:styleId': 'DefaultParagraphFont', 'w:default': '1' }, - elements: [], - }, - ], - }, - ], + translatedLinkedStyles: buildTranslatedLinkedStyles({ + Hyperlink: { + styleId: 'Hyperlink', + type: 'character', + basedOn: 'DefaultParagraphFont', + runProperties: { + color: { val: '0000FF' }, + underline: { 'w:val': 'single' }, + }, + paragraphProperties: {}, }, - }, - numbering: { definitions: {}, abstracts: {} }, + }), + translatedNumbering: { definitions: {}, abstracts: {} }, }; const inlineRpr = { @@ -235,14 +162,12 @@ describe('resolveRunProperties - inline property priority', () => { }); it('should test combineProperties with fontSize override', async () => { - const { combineProperties } = await import('./styles.js'); - const chain = [ { fontSize: 18, color: { val: 'FF0000' } }, // from character style { fontSize: 24, bold: true, color: { val: '00FF00' } }, // from inline (should win) ]; - const result = combineProperties(chain, ['color']); + const result = combineRunProperties(chain); // fontSize should be 24 (from inline) expect(result.fontSize).toBe(24); @@ -254,54 +179,20 @@ describe('resolveRunProperties - inline property priority', () => { it('should ensure all inline properties override character style properties', () => { const params = { - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:style', - attributes: { 'w:type': 'character', 'w:styleId': 's1' }, - elements: [ - { - name: 'w:basedOn', - attributes: { 'w:val': 'DefaultParagraphFont' }, - }, - { - name: 'w:rPr', - elements: [ - { - name: 'w:sz', - attributes: { 'w:val': '18' }, // 9pt from style - }, - { - name: 'w:b', - attributes: {}, // bold from style - }, - { - name: 'w:i', - attributes: {}, // italic from style - }, - ], - }, - ], - }, - { - name: 'w:style', - attributes: { 'w:type': 'paragraph', 'w:styleId': 'Normal', 'w:default': '1' }, - elements: [], - }, - { - name: 'w:style', - attributes: { 'w:type': 'character', 'w:styleId': 'DefaultParagraphFont', 'w:default': '1' }, - elements: [], - }, - ], - }, - ], + translatedLinkedStyles: buildTranslatedLinkedStyles({ + s1: { + styleId: 's1', + type: 'character', + basedOn: 'DefaultParagraphFont', + runProperties: { + fontSize: 18, + bold: true, + italic: true, + }, + paragraphProperties: {}, }, - }, - numbering: { definitions: {}, abstracts: {} }, + }), + translatedNumbering: { definitions: {}, abstracts: {} }, }; // Inline run properties: override fontSize, bold, italic with different values diff --git a/packages/super-editor/src/core/super-converter/styles.js b/packages/super-editor/src/core/super-converter/styles.js index eb43c03b25..f05a8ca815 100644 --- a/packages/super-editor/src/core/super-converter/styles.js +++ b/packages/super-editor/src/core/super-converter/styles.js @@ -8,15 +8,17 @@ import { eighthPointsToPixels, linesToTwips, } from '@converter/helpers.js'; -import { translator as w_pPrTranslator } from '@converter/v3/handlers/w/pPr'; -import { translator as w_rPrTranslator } from '@converter/v3/handlers/w/rpr'; import { isValidHexColor, getHexColorFromDocxSystem } from '@converter/helpers'; import { SuperConverter } from '@converter/SuperConverter.js'; import { getUnderlineCssString } from '@extensions/linked-styles/underline-css.js'; -import { createOoxmlResolver, resolveDocxFontFamily } from '@superdoc/style-engine/ooxml'; -import { combineProperties as _combineProperties } from '@superdoc/style-engine'; +import { + resolveDocxFontFamily, + resolveRunProperties, + resolveParagraphProperties, + combineRunProperties, +} from '@superdoc/style-engine/ooxml'; -const ooxmlResolver = createOoxmlResolver({ pPr: w_pPrTranslator, rPr: w_rPrTranslator }); +export { resolveRunProperties, resolveParagraphProperties, combineRunProperties }; /** * Font family converter from SuperConverter (lazy getter to avoid circular import) @@ -35,88 +37,6 @@ const getToCssFontFamily = () => { */ const SUBSCRIPT_SUPERSCRIPT_SCALE = 0.65; -/** - * Gets the resolved run properties by merging defaults, styles, and inline properties. - * - * FontSize Fallback Behavior: - * - Validates that the resolved fontSize is a valid positive number - * - If fontSize is null, 0, negative, or NaN, applies fallback cascade: - * 1. Document defaults (defaultProps.fontSize) - * 2. Normal style (normalProps.fontSize) - * 3. Baseline constant (DEFAULT_FONT_SIZE_HALF_POINTS = 20 half-points = 10pt) - * - Each fallback source is validated before use (must be positive finite number) - * - Ensures all text has a valid font size, preventing rendering issues - * - * @param {import('@translator').SCEncoderConfig} params - Converter context containing docx data. - * @param {Object} inlineRpr - The inline run properties. - * @param {Object} resolvedPpr - The resolved paragraph properties. - * @param {boolean} [isListNumber=false] - Whether this run is a list number marker. When true, - * applies special handling for numbering properties and - * removes inline underlines. - * @param {boolean} [numberingDefinedInline=false] - Whether numbering is defined inline rather than - * in the style definition. When false, inline rPr - * is ignored for list numbers. - * @returns {Object} The resolved run properties. - */ -export const resolveRunProperties = ( - params, - inlineRpr, - resolvedPpr, - isListNumber = false, - numberingDefinedInline = false, -) => ooxmlResolver.resolveRunProperties(params, inlineRpr, resolvedPpr, isListNumber, numberingDefinedInline); - -/** - * Gets the resolved paragraph properties by merging defaults, styles, and inline properties. - * @param {import('@translator').SCEncoderConfig} params - * @param {Object} inlineProps - The inline paragraph properties. - * @param {boolean} [insideTable=false] - Whether the paragraph is inside a table. - * @param {boolean} [overrideInlineStyleId=false] - Whether to override the inline style ID with the one from numbering. - * @param {string | null} [tableStyleId=null] - styleId for the current table, if any. - * @returns {Object} The resolved paragraph properties. - */ -export function resolveParagraphProperties( - params, - inlineProps, - insideTable = false, - overrideInlineStyleId = false, - tableStyleId = null, -) { - return ooxmlResolver.resolveParagraphProperties( - params, - inlineProps, - insideTable, - overrideInlineStyleId, - tableStyleId, - ); -} - -export const getDefaultProperties = ooxmlResolver.getDefaultProperties; -export const getStyleProperties = ooxmlResolver.getStyleProperties; -export const getNumberingProperties = ooxmlResolver.getNumberingProperties; - -/** - * Performs a deep merge on an ordered list of property objects. - * Delegates to the single source of truth in @superdoc/style-engine. - * - * @param {Array} propertiesArray - Ordered list of property objects to combine. - * @param {Array} [fullOverrideProps=[]] - Keys that should overwrite instead of merge. - * @param {Record} [specialHandling={}] - Optional per-key merge overrides. - * @returns {Object} Combined property object. - */ -export const combineProperties = (propertiesArray, fullOverrideProps = [], specialHandling = {}) => { - return _combineProperties(propertiesArray, { fullOverrideProps, specialHandling }); -}; - -/** - * Combines run property objects while fully overriding certain keys. - * @param {Array} propertiesArray - Ordered list of run property objects. - * @returns {Object} Combined run property object. - */ -export const combineRunProperties = (propertiesArray) => { - return combineProperties(propertiesArray, ['fontFamily', 'color']); -}; - /** * Encodes run property objects into mark definitions for the editor schema. * @param {Object} runProperties - Run properties extracted from DOCX. diff --git a/packages/super-editor/src/core/super-converter/styles.test.js b/packages/super-editor/src/core/super-converter/styles.test.js index cd363da265..cf3a7697e9 100644 --- a/packages/super-editor/src/core/super-converter/styles.test.js +++ b/packages/super-editor/src/core/super-converter/styles.test.js @@ -1,12 +1,5 @@ import { describe, it, expect, vi, beforeAll } from 'vitest'; -import { - encodeMarksFromRPr, - decodeRPrFromMarks, - encodeCSSFromRPr, - encodeCSSFromPPr, - resolveRunProperties, - resolveParagraphProperties, -} from './styles.js'; +import { encodeMarksFromRPr, decodeRPrFromMarks, encodeCSSFromRPr, encodeCSSFromPPr } from './styles.js'; beforeAll(() => { vi.stubGlobal('SuperConverter', { @@ -497,1750 +490,3 @@ describe('marks encoding/decoding round-trip', () => { expect(marksFromCaps.some((m) => m.type === 'textStyle' && m.attrs.textTransform)).toBe(false); }); }); - -describe('resolveRunProperties - numId=0 handling (OOXML spec §17.9.16)', () => { - // Mock minimal params structure for numbering tests - const createMockParamsForNumbering = () => ({ - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:docDefaults', - elements: [ - { - name: 'w:rPrDefault', - elements: [{ name: 'w:rPr', elements: [] }], - }, - ], - }, - { - name: 'w:style', - attributes: { 'w:styleId': 'Normal', 'w:default': '1' }, - elements: [{ name: 'w:rPr', elements: [] }], - }, - ], - }, - ], - }, - }, - numbering: { - definitions: { - 1: { - name: 'w:num', - attributes: { 'w:numId': '1' }, - elements: [{ name: 'w:abstractNumId', attributes: { 'w:val': '0' } }], - }, - }, - abstracts: { - 0: { - name: 'w:abstractNum', - attributes: { 'w:abstractNumId': '0' }, - elements: [ - { - name: 'w:lvl', - attributes: { 'w:ilvl': '0' }, - elements: [ - { name: 'w:start', attributes: { 'w:val': '1' } }, - { name: 'w:numFmt', attributes: { 'w:val': 'decimal' } }, - { - name: 'w:rPr', - elements: [{ name: 'w:sz', attributes: { 'w:val': '24' } }], - }, - ], - }, - ], - }, - }, - }, - }); - - it('should not fetch numbering properties when numId is numeric 0', () => { - const params = createMockParamsForNumbering(); - const inlineRpr = {}; - const resolvedPpr = { - numberingProperties: { - numId: 0, - ilvl: 0, - }, - }; - - const result = resolveRunProperties(params, inlineRpr, resolvedPpr, true, false); - - // numId=0 disables numbering, so numbering properties should not be fetched - // Result should only have basic properties, no numbering-specific fontSize - expect(result.fontSize).toBe(20); // baseline fallback - }); - - it('should not fetch numbering properties when numId is string "0"', () => { - const params = createMockParamsForNumbering(); - const inlineRpr = {}; - const resolvedPpr = { - numberingProperties: { - numId: '0', - ilvl: 0, - }, - }; - - const result = resolveRunProperties(params, inlineRpr, resolvedPpr, true, false); - - // numId='0' disables numbering, so numbering properties should not be fetched - expect(result.fontSize).toBe(20); // baseline fallback - }); - - it('should fetch numbering properties when numId is valid (1)', () => { - const params = createMockParamsForNumbering(); - const inlineRpr = {}; - const resolvedPpr = { - numberingProperties: { - numId: 1, - ilvl: 0, - }, - }; - - const result = resolveRunProperties(params, inlineRpr, resolvedPpr, true, false); - - // Valid numId should fetch numbering properties including fontSize from numbering definition - expect(result.fontSize).toBe(24); // from numbering definition w:sz - }); - - it('should fetch numbering properties when numId is valid string ("1")', () => { - const params = createMockParamsForNumbering(); - const inlineRpr = {}; - const resolvedPpr = { - numberingProperties: { - numId: '1', - ilvl: 0, - }, - }; - - const result = resolveRunProperties(params, inlineRpr, resolvedPpr, true, false); - - // Valid string numId should fetch numbering properties - expect(result.fontSize).toBe(24); // from numbering definition - }); - - it('should not fetch numbering properties when numId is null', () => { - const params = createMockParamsForNumbering(); - const inlineRpr = {}; - const resolvedPpr = { - numberingProperties: { - numId: null, - ilvl: 0, - }, - }; - - const result = resolveRunProperties(params, inlineRpr, resolvedPpr, true, false); - - // null numId should not fetch numbering properties - expect(result.fontSize).toBe(20); // baseline fallback - }); - - it('should not fetch numbering properties when numId is undefined', () => { - const params = createMockParamsForNumbering(); - const inlineRpr = {}; - const resolvedPpr = { - numberingProperties: { - ilvl: 0, - // numId is undefined - }, - }; - - const result = resolveRunProperties(params, inlineRpr, resolvedPpr, true, false); - - // undefined numId should not fetch numbering properties - expect(result.fontSize).toBe(20); // baseline fallback - }); -}); - -describe('resolveParagraphProperties - numId=0 handling (OOXML spec §17.9.16)', () => { - // Mock minimal params structure - const createMockParamsForParagraph = () => ({ - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:docDefaults', - elements: [ - { - name: 'w:pPrDefault', - elements: [{ name: 'w:pPr', elements: [] }], - }, - ], - }, - { - name: 'w:style', - attributes: { 'w:styleId': 'Normal', 'w:default': '1' }, - elements: [{ name: 'w:pPr', elements: [] }], - }, - ], - }, - ], - }, - }, - numbering: { - definitions: { - 1: { - name: 'w:num', - attributes: { 'w:numId': '1' }, - elements: [{ name: 'w:abstractNumId', attributes: { 'w:val': '0' } }], - }, - }, - abstracts: { - 0: { - name: 'w:abstractNum', - attributes: { 'w:abstractNumId': '0' }, - elements: [ - { - name: 'w:lvl', - attributes: { 'w:ilvl': '0' }, - elements: [ - { name: 'w:start', attributes: { 'w:val': '1' } }, - { name: 'w:numFmt', attributes: { 'w:val': 'decimal' } }, - { - name: 'w:pPr', - elements: [ - { - name: 'w:ind', - attributes: { 'w:left': '720', 'w:hanging': '360' }, - }, - ], - }, - ], - }, - ], - }, - }, - }, - }); - - it('should treat numId=0 as disabling numbering and set numId to null', () => { - const params = createMockParamsForParagraph(); - const inlineProps = { - numberingProperties: { - numId: 0, - ilvl: 0, - }, - }; - - const result = resolveParagraphProperties(params, inlineProps, false, false, null); - - // numId=0 should be treated as disabling numbering - // The function sets numId to null internally but numberingProperties still exists with numId=0 - // The important part is that getNumberingProperties is NOT called (no numbering resolved from definitions) - expect(result.numberingProperties).toBeDefined(); - expect(result.numberingProperties.numId).toBe(0); - // No additional properties from numbering definitions should be present - expect(result.numberingProperties.format).toBeUndefined(); - }); - - it('should treat numId="0" as disabling numbering and set numId to null', () => { - const params = createMockParamsForParagraph(); - const inlineProps = { - numberingProperties: { - numId: '0', - ilvl: 0, - }, - }; - - const result = resolveParagraphProperties(params, inlineProps, false, false, null); - - // numId='0' should be treated as disabling numbering - // The function sets numId to null internally but numberingProperties still exists with numId='0' - expect(result.numberingProperties).toBeDefined(); - expect(result.numberingProperties.numId).toBe('0'); - // No additional properties from numbering definitions should be present - expect(result.numberingProperties.format).toBeUndefined(); - }); - - it('should preserve valid numId=1 and fetch numbering properties', () => { - const params = createMockParamsForParagraph(); - const inlineProps = { - numberingProperties: { - numId: 1, - ilvl: 0, - }, - }; - - const result = resolveParagraphProperties(params, inlineProps, false, false, null); - - // Valid numId should fetch numbering properties - expect(result.numberingProperties).toBeDefined(); - expect(result.numberingProperties.numId).toBe(1); - }); - - it('should preserve valid numId="5" and fetch numbering properties', () => { - const params = createMockParamsForParagraph(); - // Add definition for numId 5 - params.numbering.definitions['5'] = { - name: 'w:num', - attributes: { 'w:numId': '5' }, - elements: [{ name: 'w:abstractNumId', attributes: { 'w:val': '0' } }], - }; - const inlineProps = { - numberingProperties: { - numId: '5', - ilvl: 0, - }, - }; - - const result = resolveParagraphProperties(params, inlineProps, false, false, null); - - // Valid string numId should fetch numbering properties - expect(result.numberingProperties).toBeDefined(); - expect(result.numberingProperties.numId).toBe('5'); - }); - - it('should handle style-based numbering with numId=1', () => { - const params = createMockParamsForParagraph(); - // Add a style with numbering - params.docx['word/styles.xml'].elements[0].elements.push({ - name: 'w:style', - attributes: { 'w:styleId': 'ListParagraph' }, - elements: [ - { - name: 'w:pPr', - elements: [ - { - name: 'w:numPr', - elements: [ - { name: 'w:numId', attributes: { 'w:val': '1' } }, - { name: 'w:ilvl', attributes: { 'w:val': '0' } }, - ], - }, - ], - }, - ], - }); - - const inlineProps = { - styleId: 'ListParagraph', - }; - - const result = resolveParagraphProperties(params, inlineProps, false, false, null); - - // Style-based numbering should be resolved - expect(result.numberingProperties).toBeDefined(); - expect(result.numberingProperties.numId).toBe(1); - }); - - it('should override style numbering when inline numId=0 is present', () => { - const params = createMockParamsForParagraph(); - // Add a style with numbering - params.docx['word/styles.xml'].elements[0].elements.push({ - name: 'w:style', - attributes: { 'w:styleId': 'ListParagraph' }, - elements: [ - { - name: 'w:pPr', - elements: [ - { - name: 'w:numPr', - elements: [ - { name: 'w:numId', attributes: { 'w:val': '1' } }, - { name: 'w:ilvl', attributes: { 'w:val': '0' } }, - ], - }, - ], - }, - ], - }); - - const inlineProps = { - styleId: 'ListParagraph', - numberingProperties: { - numId: 0, // Inline override to disable numbering - ilvl: 0, - }, - }; - - const result = resolveParagraphProperties(params, inlineProps, false, false, null); - - // Inline numId=0 should disable style-based numbering - // numberingProperties will still exist with numId=0, but no properties from definitions are fetched - expect(result.numberingProperties).toBeDefined(); - expect(result.numberingProperties.numId).toBe(0); - expect(result.numberingProperties.format).toBeUndefined(); - }); - - it('should override style numbering when inline numId="0" is present', () => { - const params = createMockParamsForParagraph(); - // Add a style with numbering - params.docx['word/styles.xml'].elements[0].elements.push({ - name: 'w:style', - attributes: { 'w:styleId': 'ListParagraph' }, - elements: [ - { - name: 'w:pPr', - elements: [ - { - name: 'w:numPr', - elements: [ - { name: 'w:numId', attributes: { 'w:val': '1' } }, - { name: 'w:ilvl', attributes: { 'w:val': '0' } }, - ], - }, - ], - }, - ], - }); - - const inlineProps = { - styleId: 'ListParagraph', - numberingProperties: { - numId: '0', // Inline override to disable numbering (string form) - ilvl: 0, - }, - }; - - const result = resolveParagraphProperties(params, inlineProps, false, false, null); - - // Inline numId='0' should disable style-based numbering - // numberingProperties will still exist with numId='0', but no properties from definitions are fetched - expect(result.numberingProperties).toBeDefined(); - expect(result.numberingProperties.numId).toBe('0'); - expect(result.numberingProperties.format).toBeUndefined(); - }); -}); - -describe('resolveRunProperties - fontSize fallback', () => { - // Mock minimal params structure - const createMockParams = (defaultFontSize = null, normalFontSize = null) => ({ - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - // docDefaults - { - name: 'w:docDefaults', - elements: [ - { - name: 'w:rPrDefault', - elements: - defaultFontSize !== null - ? [ - { - name: 'w:rPr', - elements: [{ name: 'w:sz', attributes: { 'w:val': String(defaultFontSize) } }], - }, - ] - : [{ name: 'w:rPr', elements: [] }], - }, - ], - }, - // Normal style - { - name: 'w:style', - attributes: { 'w:styleId': 'Normal', 'w:default': '1' }, - elements: - normalFontSize !== null - ? [{ name: 'w:rPr', elements: [{ name: 'w:sz', attributes: { 'w:val': String(normalFontSize) } }] }] - : [{ name: 'w:rPr', elements: [] }], - }, - ], - }, - ], - }, - }, - numbering: { definitions: {}, abstracts: {} }, - }); - - it('should use inline fontSize when provided', () => { - const params = createMockParams(); - const inlineRpr = { fontSize: 28 }; // 14pt - const resolvedPpr = {}; - - const result = resolveRunProperties(params, inlineRpr, resolvedPpr); - - expect(result.fontSize).toBe(28); - }); - - it('should use defaultProps fontSize when finalProps fontSize is null', () => { - const params = createMockParams(24, null); // defaultProps has 24 (12pt) - const inlineRpr = {}; - const resolvedPpr = {}; - - const result = resolveRunProperties(params, inlineRpr, resolvedPpr); - - expect(result.fontSize).toBe(24); - }); - - it('should use normalProps fontSize when defaultProps has no fontSize', () => { - const params = createMockParams(null, 22); // normalProps has 22 (11pt) - const inlineRpr = {}; - const resolvedPpr = {}; - - const result = resolveRunProperties(params, inlineRpr, resolvedPpr); - - expect(result.fontSize).toBe(22); - }); - - it('should use 20 half-points baseline when neither defaultProps nor normalProps has fontSize', () => { - const params = createMockParams(null, null); - const inlineRpr = {}; - const resolvedPpr = {}; - - const result = resolveRunProperties(params, inlineRpr, resolvedPpr); - - expect(result.fontSize).toBe(20); // 20 half-points = 10pt baseline - }); - - it('should ignore invalid fontSize value of 0', () => { - const params = createMockParams(24, null); - const inlineRpr = { fontSize: 0 }; // Invalid: zero - const resolvedPpr = {}; - - const result = resolveRunProperties(params, inlineRpr, resolvedPpr); - - // Should fall back to defaultProps - expect(result.fontSize).toBe(24); - }); - - it('should ignore negative fontSize values', () => { - const params = createMockParams(null, 22); - const inlineRpr = { fontSize: -10 }; // Invalid: negative - const resolvedPpr = {}; - - const result = resolveRunProperties(params, inlineRpr, resolvedPpr); - - // Should fall back to normalProps - expect(result.fontSize).toBe(22); - }); - - it('should ignore NaN fontSize values', () => { - const params = createMockParams(null, null); - const inlineRpr = { fontSize: NaN }; // Invalid: NaN - const resolvedPpr = {}; - - const result = resolveRunProperties(params, inlineRpr, resolvedPpr); - - // Should fall back to baseline - expect(result.fontSize).toBe(20); - }); - - it('should ignore Infinity fontSize values', () => { - const params = createMockParams(24, null); - const inlineRpr = { fontSize: Infinity }; // Invalid: Infinity - const resolvedPpr = {}; - - const result = resolveRunProperties(params, inlineRpr, resolvedPpr); - - // Should fall back to defaultProps - expect(result.fontSize).toBe(24); - }); - - it('should preserve valid fontSize from inline formatting', () => { - const params = createMockParams(20, null); - const inlineRpr = { fontSize: 32 }; // Valid: 16pt - const resolvedPpr = {}; - - const result = resolveRunProperties(params, inlineRpr, resolvedPpr); - - expect(result.fontSize).toBe(32); - }); - - it('should skip invalid defaultProps fontSize and use normalProps', () => { - const params = createMockParams(null, 26); // defaultProps invalid, normalProps has 26 - // Manually set invalid defaultProps fontSize - const docDefaults = params.docx['word/styles.xml'].elements[0].elements[0]; - docDefaults.elements[0].elements = [{ name: 'w:rPr', elements: [{ name: 'w:sz', attributes: { 'w:val': '-5' } }] }]; - - const inlineRpr = {}; - const resolvedPpr = {}; - - const result = resolveRunProperties(params, inlineRpr, resolvedPpr); - - // Should skip invalid defaultProps and use normalProps - expect(result.fontSize).toBe(26); - }); - - it('should use baseline when all sources have invalid fontSize values', () => { - const params = createMockParams(null, null); - // Set both to invalid values - const elements = params.docx['word/styles.xml'].elements[0].elements; - elements[0].elements[0].elements = [{ name: 'w:rPr', elements: [{ name: 'w:sz', attributes: { 'w:val': '0' } }] }]; - elements[1].elements = [{ name: 'w:rPr', elements: [{ name: 'w:sz', attributes: { 'w:val': '-10' } }] }]; - - const inlineRpr = { fontSize: NaN }; - const resolvedPpr = {}; - - const result = resolveRunProperties(params, inlineRpr, resolvedPpr); - - // Should fall back to baseline - expect(result.fontSize).toBe(20); - }); - - it('should validate that defaultProps fontSize is a number', () => { - const params = createMockParams(null, 22); - // Manually corrupt defaultProps fontSize to be a string - const docDefaults = params.docx['word/styles.xml'].elements[0].elements[0]; - docDefaults.elements[0].elements = [ - { name: 'w:rPr', elements: [{ name: 'w:sz', attributes: { 'w:val': 'invalid' } }] }, - ]; - - const inlineRpr = {}; - const resolvedPpr = {}; - - const result = resolveRunProperties(params, inlineRpr, resolvedPpr); - - // Should skip non-number defaultProps and use normalProps - expect(result.fontSize).toBe(22); - }); -}); - -// ============================================================================= -// CRITICAL FUNCTION UNIT TESTS -// These tests directly verify the core style resolution functions that are -// essential for correct OOXML cascade behavior. They serve as a safety net -// before any refactoring of the style resolution logic. -// ============================================================================= - -describe('getDefaultProperties', () => { - // Import the function directly for unit testing - let getDefaultProperties; - - beforeAll(async () => { - const module = await import('./styles.js'); - getDefaultProperties = module.getDefaultProperties; - }); - - // Create a mock translator that extracts properties from the XML element - const createMockTranslator = (xmlName) => ({ - xmlName, - encode: (params) => { - const node = params.nodes?.[0]; - if (!node?.elements) return {}; - const result = {}; - for (const el of node.elements) { - if (el.name === 'w:sz') { - result.fontSize = parseInt(el.attributes['w:val'], 10); - } - if (el.name === 'w:b') { - result.bold = true; - } - if (el.name === 'w:i') { - result.italic = true; - } - if (el.name === 'w:rFonts') { - result.fontFamily = { ascii: el.attributes['w:ascii'] }; - } - if (el.name === 'w:ind') { - result.indent = { - left: parseInt(el.attributes['w:left'] || '0', 10), - hanging: parseInt(el.attributes['w:hanging'] || '0', 10), - }; - } - if (el.name === 'w:spacing') { - result.spacing = { - before: parseInt(el.attributes['w:before'] || '0', 10), - after: parseInt(el.attributes['w:after'] || '0', 10), - line: parseInt(el.attributes['w:line'] || '0', 10), - }; - } - } - return result; - }, - }); - - const mockRPrTranslator = createMockTranslator('w:rPr'); - const mockPPrTranslator = createMockTranslator('w:pPr'); - - it('should return empty object when styles.xml is missing', () => { - const params = { docx: {} }; - const result = getDefaultProperties(params, mockRPrTranslator); - expect(result).toEqual({}); - }); - - it('should return empty object when docDefaults is missing', () => { - const params = { - docx: { - 'word/styles.xml': { - elements: [{ elements: [] }], - }, - }, - }; - const result = getDefaultProperties(params, mockRPrTranslator); - expect(result).toEqual({}); - }); - - it('should extract run properties from w:rPrDefault', () => { - const params = { - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:docDefaults', - elements: [ - { - name: 'w:rPrDefault', - elements: [ - { - name: 'w:rPr', - elements: [ - { name: 'w:sz', attributes: { 'w:val': '22' } }, - { name: 'w:rFonts', attributes: { 'w:ascii': 'Calibri' } }, - ], - }, - ], - }, - ], - }, - ], - }, - ], - }, - }, - }; - - const result = getDefaultProperties(params, mockRPrTranslator); - - expect(result.fontSize).toBe(22); - expect(result.fontFamily).toEqual({ ascii: 'Calibri' }); - }); - - it('should extract paragraph properties from w:pPrDefault', () => { - const params = { - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:docDefaults', - elements: [ - { - name: 'w:pPrDefault', - elements: [ - { - name: 'w:pPr', - elements: [ - { name: 'w:spacing', attributes: { 'w:after': '200', 'w:line': '276' } }, - { name: 'w:ind', attributes: { 'w:left': '720' } }, - ], - }, - ], - }, - ], - }, - ], - }, - ], - }, - }, - }; - - const result = getDefaultProperties(params, mockPPrTranslator); - - expect(result.spacing).toEqual({ before: 0, after: 200, line: 276 }); - expect(result.indent).toEqual({ left: 720, hanging: 0 }); - }); - - it('should return empty object when rPrDefault exists but has no rPr child', () => { - const params = { - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:docDefaults', - elements: [ - { - name: 'w:rPrDefault', - elements: [], // No w:rPr child - }, - ], - }, - ], - }, - ], - }, - }, - }; - - const result = getDefaultProperties(params, mockRPrTranslator); - expect(result).toEqual({}); - }); - - it('should handle both rPrDefault and pPrDefault in the same document', () => { - const params = { - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:docDefaults', - elements: [ - { - name: 'w:rPrDefault', - elements: [ - { - name: 'w:rPr', - elements: [{ name: 'w:sz', attributes: { 'w:val': '24' } }], - }, - ], - }, - { - name: 'w:pPrDefault', - elements: [ - { - name: 'w:pPr', - elements: [{ name: 'w:spacing', attributes: { 'w:after': '160' } }], - }, - ], - }, - ], - }, - ], - }, - ], - }, - }, - }; - - const rPrResult = getDefaultProperties(params, mockRPrTranslator); - const pPrResult = getDefaultProperties(params, mockPPrTranslator); - - expect(rPrResult.fontSize).toBe(24); - expect(pPrResult.spacing).toEqual({ before: 0, after: 160, line: 0 }); - }); -}); - -describe('getStyleProperties', () => { - let getStyleProperties; - - beforeAll(async () => { - const module = await import('./styles.js'); - getStyleProperties = module.getStyleProperties; - }); - - const createMockTranslator = (xmlName) => ({ - xmlName, - encode: (params) => { - const node = params.nodes?.[0]; - if (!node?.elements) return {}; - const result = {}; - for (const el of node.elements) { - if (el.name === 'w:sz') { - result.fontSize = parseInt(el.attributes['w:val'], 10); - } - if (el.name === 'w:b') { - result.bold = true; - } - if (el.name === 'w:color') { - result.color = { val: el.attributes['w:val'] }; - } - if (el.name === 'w:ind') { - result.indent = { - left: parseInt(el.attributes['w:left'] || '0', 10), - }; - } - } - return result; - }, - }); - - const mockRPrTranslator = createMockTranslator('w:rPr'); - - it('should return empty result for null styleId', () => { - const params = { docx: { 'word/styles.xml': { elements: [{ elements: [] }] } } }; - const result = getStyleProperties(params, null, mockRPrTranslator); - - expect(result).toEqual({ properties: {}, isDefault: false, basedOn: undefined }); - }); - - it('should return empty result when style is not found', () => { - const params = { - docx: { - 'word/styles.xml': { - elements: [{ elements: [] }], - }, - }, - }; - const result = getStyleProperties(params, 'NonExistentStyle', mockRPrTranslator); - - expect(result.properties).toEqual({}); - expect(result.isDefault).toBe(false); - expect(result.basedOn).toBeUndefined(); - }); - - it('should extract properties from a style definition', () => { - const params = { - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:style', - attributes: { 'w:styleId': 'Heading1', 'w:type': 'paragraph' }, - elements: [ - { - name: 'w:rPr', - elements: [ - { name: 'w:sz', attributes: { 'w:val': '32' } }, - { name: 'w:b', attributes: {} }, - ], - }, - ], - }, - ], - }, - ], - }, - }, - }; - - const result = getStyleProperties(params, 'Heading1', mockRPrTranslator); - - expect(result.properties.fontSize).toBe(32); - expect(result.properties.bold).toBe(true); - expect(result.isDefault).toBe(false); - expect(result.basedOn).toBeUndefined(); - }); - - it('should correctly identify default styles (w:default="1")', () => { - const params = { - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:style', - attributes: { 'w:styleId': 'Normal', 'w:type': 'paragraph', 'w:default': '1' }, - elements: [ - { - name: 'w:rPr', - elements: [{ name: 'w:sz', attributes: { 'w:val': '22' } }], - }, - ], - }, - ], - }, - ], - }, - }, - }; - - const result = getStyleProperties(params, 'Normal', mockRPrTranslator); - - expect(result.isDefault).toBe(true); - expect(result.properties.fontSize).toBe(22); - }); - - it('should extract basedOn reference correctly', () => { - const params = { - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:style', - attributes: { 'w:styleId': 'Heading1', 'w:type': 'paragraph' }, - elements: [ - { name: 'w:basedOn', attributes: { 'w:val': 'Normal' } }, - { - name: 'w:rPr', - elements: [{ name: 'w:sz', attributes: { 'w:val': '32' } }], - }, - ], - }, - ], - }, - ], - }, - }, - }; - - const result = getStyleProperties(params, 'Heading1', mockRPrTranslator); - - expect(result.basedOn).toBe('Normal'); - expect(result.properties.fontSize).toBe(32); - }); - - it('should return basedOn even when style has no properties element', () => { - const params = { - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:style', - attributes: { 'w:styleId': 'NoIndent', 'w:type': 'paragraph' }, - elements: [{ name: 'w:basedOn', attributes: { 'w:val': 'Normal' } }], - // No w:rPr element - }, - ], - }, - ], - }, - }, - }; - - const result = getStyleProperties(params, 'NoIndent', mockRPrTranslator); - - expect(result.basedOn).toBe('Normal'); - expect(result.properties).toEqual({}); - }); - - it('should handle multiple styles and find the correct one', () => { - const params = { - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:style', - attributes: { 'w:styleId': 'Normal', 'w:type': 'paragraph', 'w:default': '1' }, - elements: [ - { - name: 'w:rPr', - elements: [{ name: 'w:sz', attributes: { 'w:val': '22' } }], - }, - ], - }, - { - name: 'w:style', - attributes: { 'w:styleId': 'Heading1', 'w:type': 'paragraph' }, - elements: [ - { name: 'w:basedOn', attributes: { 'w:val': 'Normal' } }, - { - name: 'w:rPr', - elements: [ - { name: 'w:sz', attributes: { 'w:val': '32' } }, - { name: 'w:b', attributes: {} }, - ], - }, - ], - }, - { - name: 'w:style', - attributes: { 'w:styleId': 'Heading2', 'w:type': 'paragraph' }, - elements: [ - { name: 'w:basedOn', attributes: { 'w:val': 'Normal' } }, - { - name: 'w:rPr', - elements: [{ name: 'w:sz', attributes: { 'w:val': '26' } }], - }, - ], - }, - ], - }, - ], - }, - }, - }; - - const heading1Result = getStyleProperties(params, 'Heading1', mockRPrTranslator); - const heading2Result = getStyleProperties(params, 'Heading2', mockRPrTranslator); - const normalResult = getStyleProperties(params, 'Normal', mockRPrTranslator); - - expect(heading1Result.properties.fontSize).toBe(32); - expect(heading1Result.properties.bold).toBe(true); - expect(heading1Result.basedOn).toBe('Normal'); - - expect(heading2Result.properties.fontSize).toBe(26); - expect(heading2Result.basedOn).toBe('Normal'); - - expect(normalResult.properties.fontSize).toBe(22); - expect(normalResult.isDefault).toBe(true); - }); -}); - -describe('getNumberingProperties', () => { - let getNumberingProperties; - - beforeAll(async () => { - const module = await import('./styles.js'); - getNumberingProperties = module.getNumberingProperties; - }); - - // Mock translator for pPr - const createMockPPrTranslator = () => ({ - xmlName: 'w:pPr', - encode: (params) => { - const node = params.nodes?.[0]; - if (!node?.elements) return {}; - const result = {}; - for (const el of node.elements) { - if (el.name === 'w:ind') { - result.indent = { - left: parseInt(el.attributes['w:left'] || '0', 10), - hanging: parseInt(el.attributes['w:hanging'] || '0', 10), - }; - } - if (el.name === 'w:numPr') { - result.numberingProperties = {}; - for (const numEl of el.elements || []) { - if (numEl.name === 'w:numId') { - result.numberingProperties.numId = parseInt(numEl.attributes['w:val'], 10); - } - if (numEl.name === 'w:ilvl') { - result.numberingProperties.ilvl = parseInt(numEl.attributes['w:val'], 10); - } - } - } - } - return result; - }, - }); - - // Mock translator for rPr - const createMockRPrTranslator = () => ({ - xmlName: 'w:rPr', - encode: (params) => { - const node = params.nodes?.[0]; - if (!node?.elements) return {}; - const result = {}; - for (const el of node.elements) { - if (el.name === 'w:sz') { - result.fontSize = parseInt(el.attributes['w:val'], 10); - } - if (el.name === 'w:b') { - result.bold = true; - } - if (el.name === 'w:rFonts') { - result.fontFamily = { ascii: el.attributes['w:ascii'] }; - } - } - return result; - }, - }); - - const mockPPrTranslator = createMockPPrTranslator(); - const mockRPrTranslator = createMockRPrTranslator(); - - it('should return empty object when numbering definitions are missing', () => { - const params = { numbering: null }; - const result = getNumberingProperties(params, 0, 1, mockPPrTranslator); - expect(result).toEqual({}); - }); - - it('should return empty object when numId is not found', () => { - const params = { - numbering: { - definitions: {}, - abstracts: {}, - }, - }; - const result = getNumberingProperties(params, 0, 999, mockPPrTranslator); - expect(result).toEqual({}); - }); - - it('should extract basic paragraph properties from numbering level', () => { - const params = { - numbering: { - definitions: { - 1: { - name: 'w:num', - attributes: { 'w:numId': '1' }, - elements: [{ name: 'w:abstractNumId', attributes: { 'w:val': '0' } }], - }, - }, - abstracts: { - 0: { - name: 'w:abstractNum', - attributes: { 'w:abstractNumId': '0' }, - elements: [ - { - name: 'w:lvl', - attributes: { 'w:ilvl': '0' }, - elements: [ - { - name: 'w:pPr', - elements: [{ name: 'w:ind', attributes: { 'w:left': '720', 'w:hanging': '360' } }], - }, - ], - }, - ], - }, - }, - }, - }; - - const result = getNumberingProperties(params, 0, 1, mockPPrTranslator); - - expect(result.indent).toEqual({ left: 720, hanging: 360 }); - }); - - it('should extract run properties from numbering level', () => { - const params = { - numbering: { - definitions: { - 1: { - name: 'w:num', - attributes: { 'w:numId': '1' }, - elements: [{ name: 'w:abstractNumId', attributes: { 'w:val': '0' } }], - }, - }, - abstracts: { - 0: { - name: 'w:abstractNum', - attributes: { 'w:abstractNumId': '0' }, - elements: [ - { - name: 'w:lvl', - attributes: { 'w:ilvl': '0' }, - elements: [ - { - name: 'w:rPr', - elements: [ - { name: 'w:sz', attributes: { 'w:val': '24' } }, - { name: 'w:b', attributes: {} }, - ], - }, - ], - }, - ], - }, - }, - }, - }; - - const result = getNumberingProperties(params, 0, 1, mockRPrTranslator); - - expect(result.fontSize).toBe(24); - expect(result.bold).toBe(true); - }); - - it('should handle multiple levels and return correct level properties', () => { - const params = { - numbering: { - definitions: { - 1: { - name: 'w:num', - attributes: { 'w:numId': '1' }, - elements: [{ name: 'w:abstractNumId', attributes: { 'w:val': '0' } }], - }, - }, - abstracts: { - 0: { - name: 'w:abstractNum', - attributes: { 'w:abstractNumId': '0' }, - elements: [ - { - name: 'w:lvl', - attributes: { 'w:ilvl': '0' }, - elements: [ - { - name: 'w:pPr', - elements: [{ name: 'w:ind', attributes: { 'w:left': '720', 'w:hanging': '360' } }], - }, - ], - }, - { - name: 'w:lvl', - attributes: { 'w:ilvl': '1' }, - elements: [ - { - name: 'w:pPr', - elements: [{ name: 'w:ind', attributes: { 'w:left': '1440', 'w:hanging': '360' } }], - }, - ], - }, - { - name: 'w:lvl', - attributes: { 'w:ilvl': '2' }, - elements: [ - { - name: 'w:pPr', - elements: [{ name: 'w:ind', attributes: { 'w:left': '2160', 'w:hanging': '360' } }], - }, - ], - }, - ], - }, - }, - }, - }; - - const level0 = getNumberingProperties(params, 0, 1, mockPPrTranslator); - const level1 = getNumberingProperties(params, 1, 1, mockPPrTranslator); - const level2 = getNumberingProperties(params, 2, 1, mockPPrTranslator); - - expect(level0.indent.left).toBe(720); - expect(level1.indent.left).toBe(1440); - expect(level2.indent.left).toBe(2160); - }); - - it('should apply lvlOverride properties on top of abstract level properties', () => { - const params = { - numbering: { - definitions: { - 1: { - name: 'w:num', - attributes: { 'w:numId': '1' }, - elements: [ - { name: 'w:abstractNumId', attributes: { 'w:val': '0' } }, - { - name: 'w:lvlOverride', - attributes: { 'w:ilvl': '0' }, - elements: [ - { - name: 'w:pPr', - elements: [{ name: 'w:ind', attributes: { 'w:left': '1080', 'w:hanging': '540' } }], - }, - ], - }, - ], - }, - }, - abstracts: { - 0: { - name: 'w:abstractNum', - attributes: { 'w:abstractNumId': '0' }, - elements: [ - { - name: 'w:lvl', - attributes: { 'w:ilvl': '0' }, - elements: [ - { - name: 'w:pPr', - elements: [{ name: 'w:ind', attributes: { 'w:left': '720', 'w:hanging': '360' } }], - }, - ], - }, - ], - }, - }, - }, - }; - - const result = getNumberingProperties(params, 0, 1, mockPPrTranslator); - - // lvlOverride should override the abstract level properties - expect(result.indent).toEqual({ left: 1080, hanging: 540 }); - }); - - it('should extract pStyle from level definition', () => { - const params = { - numbering: { - definitions: { - 1: { - name: 'w:num', - attributes: { 'w:numId': '1' }, - elements: [{ name: 'w:abstractNumId', attributes: { 'w:val': '0' } }], - }, - }, - abstracts: { - 0: { - name: 'w:abstractNum', - attributes: { 'w:abstractNumId': '0' }, - elements: [ - { - name: 'w:lvl', - attributes: { 'w:ilvl': '0' }, - elements: [ - { name: 'w:pStyle', attributes: { 'w:val': 'ListParagraph' } }, - { - name: 'w:pPr', - elements: [{ name: 'w:ind', attributes: { 'w:left': '720', 'w:hanging': '360' } }], - }, - ], - }, - ], - }, - }, - }, - }; - - const result = getNumberingProperties(params, 0, 1, mockPPrTranslator); - - expect(result.styleId).toBe('ListParagraph'); - }); - - it('should follow numStyleLink to resolve properties', () => { - const params = { - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:style', - attributes: { 'w:styleId': 'ListBullet', 'w:type': 'numbering' }, - elements: [ - { - name: 'w:pPr', - elements: [ - { - name: 'w:numPr', - elements: [ - { name: 'w:numId', attributes: { 'w:val': '2' } }, - { name: 'w:ilvl', attributes: { 'w:val': '0' } }, - ], - }, - ], - }, - ], - }, - ], - }, - ], - }, - }, - numbering: { - definitions: { - 1: { - name: 'w:num', - attributes: { 'w:numId': '1' }, - elements: [{ name: 'w:abstractNumId', attributes: { 'w:val': '0' } }], - }, - 2: { - name: 'w:num', - attributes: { 'w:numId': '2' }, - elements: [{ name: 'w:abstractNumId', attributes: { 'w:val': '1' } }], - }, - }, - abstracts: { - 0: { - name: 'w:abstractNum', - attributes: { 'w:abstractNumId': '0' }, - elements: [{ name: 'w:numStyleLink', attributes: { 'w:val': 'ListBullet' } }], - }, - 1: { - name: 'w:abstractNum', - attributes: { 'w:abstractNumId': '1' }, - elements: [ - { - name: 'w:lvl', - attributes: { 'w:ilvl': '0' }, - elements: [ - { - name: 'w:pPr', - elements: [{ name: 'w:ind', attributes: { 'w:left': '360', 'w:hanging': '180' } }], - }, - ], - }, - ], - }, - }, - }, - }; - - const result = getNumberingProperties(params, 0, 1, mockPPrTranslator); - - // Should follow the numStyleLink and get properties from the linked definition - expect(result.indent).toEqual({ left: 360, hanging: 180 }); - }); - - it('should prevent infinite recursion when following numStyleLink', () => { - // Create a scenario where numStyleLink could cause infinite recursion - const params = { - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:style', - attributes: { 'w:styleId': 'CircularStyle', 'w:type': 'numbering' }, - elements: [ - { - name: 'w:pPr', - elements: [ - { - name: 'w:numPr', - elements: [{ name: 'w:numId', attributes: { 'w:val': '1' } }], - }, - ], - }, - ], - }, - ], - }, - ], - }, - }, - numbering: { - definitions: { - 1: { - name: 'w:num', - attributes: { 'w:numId': '1' }, - elements: [{ name: 'w:abstractNumId', attributes: { 'w:val': '0' } }], - }, - }, - abstracts: { - 0: { - name: 'w:abstractNum', - attributes: { 'w:abstractNumId': '0' }, - elements: [ - { name: 'w:numStyleLink', attributes: { 'w:val': 'CircularStyle' } }, - // Also has a level definition as fallback - { - name: 'w:lvl', - attributes: { 'w:ilvl': '0' }, - elements: [ - { - name: 'w:pPr', - elements: [{ name: 'w:ind', attributes: { 'w:left': '720', 'w:hanging': '360' } }], - }, - ], - }, - ], - }, - }, - }, - }; - - // Should not hang or throw - the tries parameter prevents infinite recursion - const result = getNumberingProperties(params, 0, 1, mockPPrTranslator); - - // After following the link once and hitting the recursion limit, it should return empty - // or the fallback level properties depending on implementation - expect(result).toBeDefined(); - }); -}); - -describe('isNormalDefault ordering logic', () => { - /** - * Tests for the critical logic that determines whether document defaults - * or Normal style should take precedence in the cascade. - * - * Per OOXML spec, when Normal style is marked as w:default="1", it should - * come AFTER document defaults in the cascade (so defaults override Normal). - * When Normal is NOT the default style, Normal should come BEFORE defaults. - */ - - it('should apply defaults AFTER Normal when Normal is marked as default (isNormalDefault=true)', () => { - // When Normal is the default style, the cascade should be: - // [defaultProps, normalProps] - so defaultProps values win when both exist - const params = { - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:docDefaults', - elements: [ - { - name: 'w:rPrDefault', - elements: [ - { - name: 'w:rPr', - elements: [{ name: 'w:sz', attributes: { 'w:val': '24' } }], // 12pt from defaults - }, - ], - }, - ], - }, - { - name: 'w:style', - attributes: { 'w:styleId': 'Normal', 'w:default': '1' }, // IS default - elements: [ - { - name: 'w:rPr', - elements: [{ name: 'w:sz', attributes: { 'w:val': '22' } }], // 11pt from Normal - }, - ], - }, - ], - }, - ], - }, - }, - numbering: { definitions: {}, abstracts: {} }, - }; - - const result = resolveRunProperties(params, {}, {}); - - // When Normal is default, chain is [defaultProps, normalProps] - // normalProps comes LAST, so Normal's 22 (11pt) should win - expect(result.fontSize).toBe(22); - }); - - it('should apply Normal AFTER defaults when Normal is NOT marked as default (isNormalDefault=false)', () => { - // When Normal is NOT the default style, the cascade should be: - // [normalProps, defaultProps] - so defaults values win when both exist - const params = { - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:docDefaults', - elements: [ - { - name: 'w:rPrDefault', - elements: [ - { - name: 'w:rPr', - elements: [{ name: 'w:sz', attributes: { 'w:val': '24' } }], // 12pt from defaults - }, - ], - }, - ], - }, - { - name: 'w:style', - attributes: { 'w:styleId': 'Normal' }, // NOT marked as default - elements: [ - { - name: 'w:rPr', - elements: [{ name: 'w:sz', attributes: { 'w:val': '22' } }], // 11pt from Normal - }, - ], - }, - ], - }, - ], - }, - }, - numbering: { definitions: {}, abstracts: {} }, - }; - - const result = resolveRunProperties(params, {}, {}); - - // When Normal is NOT default, chain is [normalProps, defaultProps] - // defaultProps comes LAST, so defaults' 24 (12pt) should win - expect(result.fontSize).toBe(24); - }); - - it('should use Normal properties when defaults have no value', () => { - const params = { - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:docDefaults', - elements: [ - { - name: 'w:rPrDefault', - elements: [ - { - name: 'w:rPr', - elements: [], // No fontSize in defaults - }, - ], - }, - ], - }, - { - name: 'w:style', - attributes: { 'w:styleId': 'Normal', 'w:default': '1' }, - elements: [ - { - name: 'w:rPr', - elements: [{ name: 'w:sz', attributes: { 'w:val': '28' } }], // 14pt from Normal - }, - ], - }, - ], - }, - ], - }, - }, - numbering: { definitions: {}, abstracts: {} }, - }; - - const result = resolveRunProperties(params, {}, {}); - - // Normal's fontSize should be used since defaults has none - expect(result.fontSize).toBe(28); - }); - - it('should use defaults properties when Normal has no value', () => { - const params = { - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:docDefaults', - elements: [ - { - name: 'w:rPrDefault', - elements: [ - { - name: 'w:rPr', - elements: [{ name: 'w:sz', attributes: { 'w:val': '26' } }], // 13pt from defaults - }, - ], - }, - ], - }, - { - name: 'w:style', - attributes: { 'w:styleId': 'Normal', 'w:default': '1' }, - elements: [ - { - name: 'w:rPr', - elements: [], // No fontSize in Normal - }, - ], - }, - ], - }, - ], - }, - }, - numbering: { definitions: {}, abstracts: {} }, - }; - - const result = resolveRunProperties(params, {}, {}); - - // defaults' fontSize should be used since Normal has none - expect(result.fontSize).toBe(26); - }); - - it('should apply the same ordering logic for paragraph properties', () => { - const params = { - docx: { - 'word/styles.xml': { - elements: [ - { - elements: [ - { - name: 'w:docDefaults', - elements: [ - { - name: 'w:pPrDefault', - elements: [ - { - name: 'w:pPr', - elements: [{ name: 'w:spacing', attributes: { 'w:after': '200' } }], - }, - ], - }, - ], - }, - { - name: 'w:style', - attributes: { 'w:styleId': 'Normal', 'w:default': '1' }, - elements: [ - { - name: 'w:pPr', - elements: [{ name: 'w:spacing', attributes: { 'w:after': '160' } }], - }, - ], - }, - ], - }, - ], - }, - }, - numbering: { definitions: {}, abstracts: {} }, - }; - - const result = resolveParagraphProperties(params, {}, false, false, null); - - // When Normal is default, Normal comes last in chain and wins - expect(result.spacing?.after).toBe(160); - }); -}); diff --git a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js index ed2f0d481b..134e3919c3 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js @@ -34,6 +34,8 @@ import { permStartHandlerEntity } from './permStartImporter.js'; import { permEndHandlerEntity } from './permEndImporter.js'; import bookmarkStartAttrConfigs from '@converter/v3/handlers/w/bookmark-start/attributes/index.js'; import bookmarkEndAttrConfigs from '@converter/v3/handlers/w/bookmark-end/attributes/index.js'; +import { translator as wStylesTranslator } from '@converter/v3/handlers/w/styles/index.js'; +import { translator as wNumberingTranslator } from '@converter/v3/handlers/w/numbering/index.js'; /** * @typedef {import()} XmlNode @@ -142,12 +144,18 @@ export const createDocumentJson = (docx, converter, editor) => { const numbering = getNumberingDefinitions(docx); const comments = importCommentData({ docx, nodeListHandler, converter, editor }); const footnotes = importFootnoteData({ docx, nodeListHandler, converter, editor, numbering }); + + const translatedLinkedStyles = translateStyleDefinitions(docx); + const translatedNumbering = translateNumberingDefinitions(docx); + let parsedContent = nodeListHandler.handler({ nodes: content, nodeListHandler, docx, converter, numbering, + translatedNumbering, + translatedLinkedStyles, editor, inlineDocumentFonts, lists, @@ -171,12 +179,22 @@ export const createDocumentJson = (docx, converter, editor) => { return { pmDoc: result, savedTagsToRestore: node, - pageStyles: getDocumentStyles(node, docx, converter, editor, numbering), + pageStyles: getDocumentStyles( + node, + docx, + converter, + editor, + numbering, + translatedNumbering, + translatedLinkedStyles, + ), comments, footnotes, inlineDocumentFonts, linkedStyles: getStyleDefinitions(docx, converter, editor), + translatedLinkedStyles, numbering: getNumberingDefinitions(docx, converter), + translatedNumbering, themeColors: getThemeColorPalette(docx), }; } @@ -259,6 +277,8 @@ const createNodeListHandler = (nodeHandlers) => { insideTrackChange, converter, numbering, + translatedNumbering, + translatedLinkedStyles, editor, filename, parentStyleId, @@ -292,6 +312,8 @@ const createNodeListHandler = (nodeHandlers) => { insideTrackChange, converter, numbering, + translatedNumbering, + translatedLinkedStyles, editor, filename, parentStyleId, @@ -412,7 +434,7 @@ function importFootnotePropertiesFromSettings(docx, converter) { * @param {Editor} editor instance. * @returns {Object} The document styles object */ -function getDocumentStyles(node, docx, converter, editor, numbering) { +function getDocumentStyles(node, docx, converter, editor, numbering, translatedNumbering, translatedLinkedStyles) { const sectPr = node.elements?.find((n) => n.name === 'w:sectPr'); const styles = {}; @@ -461,7 +483,7 @@ function getDocumentStyles(node, docx, converter, editor, numbering) { }); // Import headers and footers. Stores them in converter.headers and converter.footers - importHeadersFooters(docx, converter, editor, numbering); + importHeadersFooters(docx, converter, editor, numbering, translatedNumbering, translatedLinkedStyles); styles.alternateHeaders = isAlternatingHeadersOddEven(docx); return styles; } @@ -567,6 +589,22 @@ function getStyleDefinitions(docx) { return allParsedStyles; } +export function translateStyleDefinitions(docx) { + const styles = docx['word/styles.xml']; + if (!styles) return []; + const stylesElement = styles.elements[0]; + const parsedStyles = wStylesTranslator.encode({ nodes: [stylesElement] }); + return parsedStyles; +} + +function translateNumberingDefinitions(docx) { + const numbering = docx['word/numbering.xml']; + if (!numbering) return null; + const numberingElement = numbering.elements[0]; + const parsedNumbering = wNumberingTranslator.encode({ nodes: [numberingElement] }); + return parsedNumbering; +} + /** * Add default styles if missing. Default styles are: * @@ -600,12 +638,11 @@ export function addDefaultStylesIfMissing(styles) { * @param {Object} converter The converter instance * @param {Editor} mainEditor The editor instance */ -const importHeadersFooters = (docx, converter, mainEditor) => { +const importHeadersFooters = (docx, converter, mainEditor, numbering, translatedNumbering, translatedLinkedStyles) => { const rels = docx['word/_rels/document.xml.rels']; const relationships = rels?.elements.find((el) => el.name === 'Relationships'); const { elements } = relationships || { elements: [] }; - const numbering = getNumberingDefinitions(docx); const headerType = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/header'; const footerType = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer'; const headers = elements.filter((el) => el.attributes['Type'] === headerType); @@ -639,6 +676,8 @@ const importHeadersFooters = (docx, converter, mainEditor) => { docx, converter, numbering, + translatedNumbering, + translatedLinkedStyles, editor, filename: currentFileName, path: [], diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/index.js index 5cad99c8b0..9854a1bf24 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/index.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/index.js @@ -6,10 +6,15 @@ import { translator as sd_index_translator } from './sd/index/index-translator.j import { translator as sd_indexEntry_translator } from './sd/indexEntry/indexEntry-translator.js'; import { translator as sd_autoPageNumber_translator } from './sd/autoPageNumber/autoPageNumber-translator.js'; import { translator as sd_totalPageNumber_translator } from './sd/totalPageNumber/totalPageNumber-translator.js'; +import { translator as w_abstractNum_translator } from './w/abstractNum/abstractNum-translator.js'; +import { translator as w_abstractNumId_translator } from './w/abstractNumId/abstractNumId-translator.js'; import { translator as w_adjustRightInd_translator } from './w/adjustRightInd/adjustRightInd-translator.js'; +import { translator as w_autoRedefine_translator } from './w/autoRedefine/autoRedefine-translator.js'; import { translator as w_autoSpaceDE_translator } from './w/autoSpaceDE/autoSpaceDE-translator.js'; import { translator as w_autoSpaceDN_translator } from './w/autoSpaceDN/autoSpaceDN-translator.js'; +import { translator as w_aliases_translator } from './w/aliases/aliases-translator.js'; import { translator as w_b_translator } from './w/b/b-translator.js'; +import { translator as w_basedOn_translator } from './w/basedOn/basedOn-translator.js'; import { translator as w_bdr_translator } from './w/bdr/bdr-translator.js'; import { translator as w_bar_translator } from './w/bar/bar-translator.js'; import { translator as w_bCs_translator } from './w/bCs/bCs-translator.js'; @@ -31,6 +36,7 @@ import { translator as w_contextualSpacing } from './w/contextualSpacing/context import { translator as w_cs } from './w/cs/cs-translator.js'; import { translator as w_del_translator } from './w/del/del-translator.js'; import { translator as w_divId_translator } from './w/divId/divId-translator.js'; +import { translator as w_docDefaults_translator } from './w/docDefaults/docDefaults-translator.js'; import { translator as w_drawing_translator } from './w/drawing/drawing-translator.js'; import { translator as w_dstrike_translator } from './w/dstrike/dstrike-translator.js'; import { translator as w_eastAsianLayout_translator } from './w/eastAsianLayout/eastAsianLayout-translator.js'; @@ -58,18 +64,39 @@ import { translator as w_ind_translator } from './w/ind/ind-translator.js'; import { translator as w_ins_translator } from './w/ins/ins-translator.js'; import { translator as w_insideH_translator } from './w/insideH/insideH-translator.js'; import { translator as w_insideV_translator } from './w/insideV/insideV-translator.js'; +import { translator as w_isLgl_translator } from './w/isLgl/isLgl-translator.js'; import { translator as w_jc_translator } from './w/jc/jc-translator.js'; import { translator as w_keepLines_translator } from './w/keepLines/keepLines-translator.js'; import { translator as w_keepNext_translator } from './w/keepNext/keepNext-translator.js'; import { translator as w_kern_translator } from './w/kern/kern-translator.js'; import { translator as w_kinsoku_translator } from './w/kinsoku/kinsoku-translator.js'; import { translator as w_lang_translator } from './w/lang/lang-translator.js'; +import { translator as w_latentStyles_translator } from './w/latentStyles/latentStyles-translator.js'; +import { translator as w_locked_translator } from './w/locked/locked-translator.js'; +import { translator as w_link_translator } from './w/link/link-translator.js'; +import { translator as w_lvl_translator } from './w/lvl/lvl-translator.js'; +import { translator as w_lvlOverride_translator } from './w/lvlOverride/lvlOverride-translator.js'; +import { translator as w_lvlJc_translator } from './w/lvlJc/lvlJc-translator.js'; +import { translator as w_lvlPicBulletId_translator } from './w/lvlPicBulletId/lvlPicBulletId-translator.js'; +import { translator as w_lvlRestart_translator } from './w/lvlRestart/lvlRestart-translator.js'; +import { translator as w_lvlStart_translator } from './w/start/lvlStart-translator.js'; +import { translator as w_lvlText_translator } from './w/lvlText/lvlText-translator.js'; +import { translator as w_multiLevelType_translator } from './w/multiLevelType/multiLevelType-translator.js'; import { translator as w_mirrorIndents_translator } from './w/mirrorIndents/mirrorIndents-translator.js'; import { translator as w_left_translator } from './w/left/left-translator.js'; +import { translator as w_lsdException_translator } from './w/lsdException/lsdException-translator.js'; +import { translator as w_name_translator } from './w/name/name-translator.js'; +import { translator as w_next_translator } from './w/next/next-translator.js'; import { translator as w_noProof_translator } from './w/noProof/noProof-translator.js'; import { translator as w_noWrap_translator } from './w/noWrap/noWrap-translator.js'; +import { translator as w_num_translator } from './w/num/num-translator.js'; +import { translator as w_numbering_translator } from './w/numbering/numbering-translator.js'; +import { translator as w_numFmt_translator } from './w/numFmt/numFmt-translator.js'; import { translator as w_numId_translator } from './w/numId/numId-translator.js'; +import { translator as w_numIdMacAtCleanup_translator } from './w/numIdMacAtCleanup/numIdMacAtCleanup-translator.js'; import { translator as w_numPr_translator } from './w/numPr/numPr-translator.js'; +import { translator as w_numStyleLink_translator } from './w/numStyleLink/numStyleLink-translator.js'; +import { translator as w_nsid_translator } from './w/nsid/nsid-translator.js'; import { translator as w_oMath_translator } from './w/oMath/oMath-translator.js'; import { translator as w_outline_translator } from './w/outline/outline-translator.js'; import { translator as w_outlineLvl_translator } from './w/outlineLvl/outlineLvl-translator.js'; @@ -77,6 +104,9 @@ import { translator as w_overflowPunct } from './w/overflowPunct/overflowPunct-t import { translator as w_p_translator } from './w/p/p-translator.js'; import { translator as w_pageBreakBefore_translator } from './w/pageBreakBefore/pageBreakBefore-translator.js'; import { translator as w_pBdr_translator } from './w/pBdr/pBdr-translator.js'; +import { translator as w_personal_translator } from './w/personal/personal-translator.js'; +import { translator as w_personalCompose_translator } from './w/personalCompose/personalCompose-translator.js'; +import { translator as w_personalReply_translator } from './w/personalReply/personalReply-translator.js'; import { translator as w_position_translator } from './w/position/position-translator.js'; import { translator as w_pPr_translator } from './w/pPr/pPr-translator.js'; import { translator as w_pStyle_translator } from './w/pStyle/pStyle-translator.js'; @@ -86,19 +116,26 @@ import { translator as w_r_translator } from './w/r/r-translator.js'; import { translator as w_rFonts_translator } from './w/rFonts/rFonts-translator.js'; import { translator as w_rPr_translator } from './w/rpr/rpr-translator.js'; import { translator as w_rStyle_translator } from './w/rStyle/rstyle-translator.js'; +import { translator as w_rsid_translator } from './w/rsid/rsid-translator.js'; import { translator as w_rtl_translator } from './w/rtl/rtl-translator.js'; import { translator as w_right_translator } from './w/right/right-translator.js'; import { translator as w_sdt_translator } from './w/sdt/sdt-translator.js'; +import { translator as w_semiHidden_translator } from './w/semiHidden/semiHidden-translator.js'; import { translator as w_shadow_translator } from './w/shadow/shadow-translator.js'; import { translator as w_shd_translator } from './w/shd/shd-translator.js'; import { translator as w_smallCaps_translator } from './w/smallCaps/smallCaps-translator.js'; import { translator as w_snapToGrid_translator } from './w/snapToGrid/snapToGrid-translator.js'; import { translator as w_start_translator } from './w/start/start-translator.js'; +import { translator as w_startOverride_translator } from './w/startOverride/startOverride-translator.js'; import { translator as w_strike_translator } from './w/strike/strike-translator.js'; +import { translator as w_style_translator } from './w/style/style-translator.js'; +import { translator as w_styleLink_translator } from './w/styleLink/styleLink-translator.js'; +import { translator as w_styles_translator } from './w/styles/styles-translator.js'; import { translator as w_spacing_translator } from './w/spacing/spacing-translator.js'; import { translator as w_suppressAutoHyphens_translator } from './w/suppressAutoHyphens/suppressAutoHyphens-translator.js'; import { translator as w_suppressLineNumbers_translator } from './w/suppressLineNumbers/suppressLineNumbers-translator.js'; import { translator as w_suppressOverlap_translator } from './w/suppressOverlap/suppressOverlap-translator.js'; +import { translator as w_suff_translator } from './w/suff/suff-translator.js'; import { translator as w_sz_translator } from './w/sz/sz-translator.js'; import { translator as w_szCs_translator } from './w/szcs/szcs-translator.js'; import { translator as w_t_translator } from './w/t/t-translator.js'; @@ -128,6 +165,8 @@ import { translator as w_tr_translator } from './w/tr/tr-translator.js'; import { translator as w_trHeight_translator } from './w/trHeight/trHeight-translator.js'; import { translator as w_trPr_translator } from './w/trPr/trPr-translator.js'; import { translator as w_u_translator } from './w/u/u-translator.js'; +import { translator as w_uiPriority_translator } from './w/uiPriority/uiPriority-translator.js'; +import { translator as w_unhideWhenUsed_translator } from './w/unhideWhenUsed/unhideWhenUsed-translator.js'; import { translator as w_w_translator } from './w/w/w-translator.js'; import { translator as w_wAfter_translator } from './w/wAfter/wAfter-translator.js'; import { translator as w_wBefore_translator } from './w/wBefore/wBefore-translator.js'; @@ -139,6 +178,7 @@ import { translator as w_tcFitText_translator } from './w/tcFitText/tcFitText-tr import { translator as w_tcW_translator } from './w/tcW/tcW-translator.js'; import { translator as w_textDirection_translator } from './w/textDirection/textDirection-translator.js'; import { translator as w_tl2br_translator } from './w/tl2br/tl2br-translator.js'; +import { translator as w_tmpl_translator } from './w/tmpl/tmpl-translator.js'; import { translator as w_tr2bl_translator } from './w/tr2bl/tr2bl-translator.js'; import { translator as w_tcBorders_translator } from './w/tcBorders/tcBorders-translator.js'; import { translator as w_tcMar_translator } from './w/tcMar/tcMar-translator.js'; @@ -150,6 +190,7 @@ import { translator as w_vanish_translator } from './w/vanish/vanish-translator. import { translator as w_webHidden_translator } from './w/webHidden/webHidden-translator.js'; import { translator as w_widowControl_translator } from './w/widowControl/widowControl-translator.js'; import { translator as w_wordWrap_translator } from './w/wordWrap/wordWrap-translator.js'; +import { translator as w_qFormat_translator } from './w/qFormat/qFormat-translator.js'; import { translator as wp_anchor_translator } from './wp/anchor/anchor-translator.js'; import { translator as wp_inline_translator } from './wp/inline/inline-translator.js'; @@ -166,12 +207,17 @@ const translatorList = Array.from( sd_indexEntry_translator, sd_autoPageNumber_translator, sd_totalPageNumber_translator, + w_abstractNum_translator, + w_abstractNumId_translator, w_adjustRightInd_translator, + w_autoRedefine_translator, w_autoSpaceDE_translator, w_autoSpaceDN_translator, + w_aliases_translator, w_b_translator, w_bar_translator, w_bCs_translator, + w_basedOn_translator, w_bdr_translator, w_bidiVisual_translator, w_bookmarkEnd_translator, @@ -186,6 +232,7 @@ const translatorList = Array.from( w_cs, w_del_translator, w_divId_translator, + w_docDefaults_translator, w_drawing_translator, w_dstrike_translator, w_eastAsianLayout_translator, @@ -214,18 +261,39 @@ const translatorList = Array.from( w_ins_translator, w_insideH_translator, w_insideV_translator, + w_isLgl_translator, w_jc_translator, w_keepLines_translator, w_keepNext_translator, w_kern_translator, w_kinsoku_translator, w_lang_translator, + w_latentStyles_translator, w_left_translator, + w_lsdException_translator, + w_link_translator, + w_lvl_translator, + w_lvlOverride_translator, + w_lvlJc_translator, + w_lvlPicBulletId_translator, + w_lvlRestart_translator, + w_lvlStart_translator, + w_lvlText_translator, + w_multiLevelType_translator, + w_locked_translator, w_mirrorIndents_translator, + w_name_translator, + w_next_translator, w_noProof_translator, w_noWrap_translator, + w_num_translator, + w_numbering_translator, + w_numFmt_translator, w_numId_translator, + w_numIdMacAtCleanup_translator, w_numPr_translator, + w_numStyleLink_translator, + w_nsid_translator, w_outline_translator, w_outlineLvl_translator, w_overflowPunct, @@ -233,6 +301,9 @@ const translatorList = Array.from( w_p_translator, w_pageBreakBefore_translator, w_pBdr_translator, + w_personal_translator, + w_personalCompose_translator, + w_personalReply_translator, w_position_translator, w_pPr_translator, w_pStyle_translator, @@ -242,9 +313,11 @@ const translatorList = Array.from( w_rFonts_translator, w_rPr_translator, w_rStyle_translator, + w_rsid_translator, w_rtl_translator, w_right_translator, w_sdt_translator, + w_semiHidden_translator, w_shadow_translator, w_shd_translator, w_smallCaps_translator, @@ -253,9 +326,14 @@ const translatorList = Array.from( w_suppressAutoHyphens_translator, w_suppressLineNumbers_translator, w_suppressOverlap_translator, + w_suff_translator, w_specVanish_translator, w_start_translator, + w_startOverride_translator, w_strike_translator, + w_style_translator, + w_styleLink_translator, + w_styles_translator, w_sz_translator, w_szCs_translator, w_t_translator, @@ -287,6 +365,7 @@ const translatorList = Array.from( w_tcW_translator, w_textDirection_translator, w_tl2br_translator, + w_tmpl_translator, w_tr_translator, w_tr2bl_translator, w_trHeight_translator, @@ -296,6 +375,8 @@ const translatorList = Array.from( w_topLinePunct_translator, w_top_translator, w_u_translator, + w_uiPriority_translator, + w_unhideWhenUsed_translator, w_vAlign_translator, w_vanish_translator, w_vertAlign_translator, @@ -306,6 +387,7 @@ const translatorList = Array.from( w_webHidden_translator, w_widowControl_translator, w_wordWrap_translator, + w_qFormat_translator, wp_anchor_translator, wp_inline_translator, w_commentRangeStart_translator, diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/utils.js b/packages/super-editor/src/core/super-converter/v3/handlers/utils.js index 90ec5503a4..9023c34e11 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/utils.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/utils.js @@ -10,6 +10,9 @@ export const generateV2HandlerEntity = (handlerName, translator) => ({ handlerName, handler: (params) => { const { nodes } = params; + if (!translator || !translator.xmlName) { + return { nodes: [], consumed: 0 }; + } if (nodes.length === 0 || nodes[0].name !== translator.xmlName) { return { nodes: [], consumed: 0 }; } @@ -290,18 +293,80 @@ export function decodeProperties(params, translatorsBySdName, properties) { return elements; } +/** + * Helper to encode properties by key (eg: w:style elements by styleId) + * @param {string} xmlName The XML element name (with namespace). + * @param {string} sdName The SuperDoc attribute name (without namespace). + * @param {import('@translator').NodeTranslator} translator The node translator to use for encoding. + * @param {import('@translator').SCEncoderConfig} params The encoding parameters containing the nodes to process. + * @param {object} node The XML node containing the elements to encode. + * @param {string} keyAttr The attribute name to use as the key in the resulting object. + * @returns {object} The encoded properties as an object keyed by the specified attribute. + */ +export function encodePropertiesByKey(xmlName, sdName, translator, params, node, keyAttr) { + const result = {}; + const elements = node.elements?.filter((el) => el.name === xmlName) || []; + if (elements.length > 0) { + const items = elements.map((el) => translator.encode({ ...params, nodes: [el] })).filter(Boolean); + if (items.length > 0) { + result[sdName] = items.reduce((acc, item) => { + if (item[keyAttr] != null) { + acc[item[keyAttr]] = item; + } + return acc; + }, {}); + } + } + + return result; +} + +/** + * Helper to decode properties by key (eg: w:style elements by styleId) + * @param {string} xmlName The XML element name (with namespace). + * @param {string} sdName The SuperDoc attribute name (without namespace). + * @param {import('@translator').NodeTranslator} translator The node translator to use for decoding. + * @param {import('@translator').SCDecoderConfig} params The decoding parameters containing the node to process. + * @param {object} attrs The attributes object containing the properties to decode. + * @param {string} keyAttr The attribute name to use as the key in the resulting object. + * @returns {Array} An array of decoded elements. + */ +export function decodePropertiesByKey(xmlName, sdName, translator, params, attrs) { + const elements = []; + if (attrs[sdName] != null) { + Object.values(attrs[sdName]).forEach((item) => { + const decoded = translator.decode({ + ...params, + node: { attrs: { [translator.sdNodeOrKeyName]: item } }, + }); + if (decoded) { + elements.push(decoded); + } + }); + } + return elements; +} + /** * Helper to create property handlers for nested properties (eg: w:tcBorders => borders) * @param {string} xmlName The XML element name (with namespace). * @param {string} sdName The SuperDoc attribute name (without namespace). - * @param {import('@translator').NodeTranslatorConfig[]} propertyTranslators An array of property translators to handle nested properties. + * @param {import('@translator').NodeTranslator[]} propertyTranslators An array of property translators to handle nested properties. * @param {object} [defaultEncodedAttrs={}] Optional default attributes to include during encoding. + * @param {import('@translator').AttrConfig[]} [attributeHandlers=[]] Optional additional attribute handlers for the nested element. * @returns {import('@translator').NodeTranslatorConfig} The nested property handler config with xmlName, sdName, encode, and decode functions. */ -export function createNestedPropertiesTranslator(xmlName, sdName, propertyTranslators, defaultEncodedAttrs = {}) { +export function createNestedPropertiesTranslator( + xmlName, + sdName, + propertyTranslators, + defaultEncodedAttrs = {}, + attributeHandlers = [], +) { const propertyTranslatorsByXmlName = {}; const propertyTranslatorsBySdName = {}; propertyTranslators.forEach((translator) => { + if (!translator) return; propertyTranslatorsByXmlName[translator.xmlName] = translator; propertyTranslatorsBySdName[translator.sdNodeOrKeyName] = translator; }); @@ -310,20 +375,22 @@ export function createNestedPropertiesTranslator(xmlName, sdName, propertyTransl xmlName: xmlName, sdNodeOrKeyName: sdName, type: NodeTranslator.translatorTypes.NODE, - attributes: [], - encode: (params) => { + attributes: attributeHandlers, + encode: (params, encodedAttrs) => { const { nodes } = params; const node = nodes[0]; // Process property translators const attributes = { ...defaultEncodedAttrs, + ...encodedAttrs, ...encodeProperties({ ...params, nodes: [node] }, propertyTranslatorsByXmlName), }; return Object.keys(attributes).length > 0 ? attributes : undefined; }, - decode: (params) => { + decode: function (params) { + const decodedAttrs = this.decodeAttributes({ node: { ...params.node, attrs: params.node.attrs[sdName] || {} } }); const currentValue = params.node.attrs?.[sdName]; // Process property translators @@ -336,7 +403,7 @@ export function createNestedPropertiesTranslator(xmlName, sdName, propertyTransl const newNode = { name: xmlName, type: 'element', - attributes: {}, + attributes: decodedAttrs, elements: elements, }; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/abstractNum/abstractNum-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/abstractNum/abstractNum-translator.js new file mode 100644 index 0000000000..436d2d110a --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/abstractNum/abstractNum-translator.js @@ -0,0 +1,76 @@ +import { NodeTranslator } from '@translator'; +import { translator as wNsidTranslator } from '../../w/nsid'; +import { translator as wTmplTranslator } from '../../w/tmpl'; +import { translator as wNameTranslator } from '../../w/name'; +import { translator as wStyleLinkTranslator } from '../../w/styleLink'; +import { translator as wNumStyleLinkTranslator } from '../../w/numStyleLink'; +import { translator as wMultiLevelTypeTranslator } from '../../w/multiLevelType'; +import { translator as wLvlTranslator } from '../../w/lvl'; +import { + createIntegerAttributeHandler, + encodeProperties, + decodeProperties, + encodePropertiesByKey, + decodePropertiesByKey, +} from '@converter/v3/handlers/utils.js'; + +const propertyTranslators = [ + wNsidTranslator, + wTmplTranslator, + wNameTranslator, + wStyleLinkTranslator, + wNumStyleLinkTranslator, + wMultiLevelTypeTranslator, +]; + +const propertyTranslatorsByXmlName = {}; +const propertyTranslatorsBySdName = {}; +propertyTranslators.forEach((translator) => { + propertyTranslatorsByXmlName[translator.xmlName] = translator; + propertyTranslatorsBySdName[translator.sdNodeOrKeyName] = translator; +}); + +/** + * The NodeTranslator instance for the w:num element. + * @type {import('@translator').NodeTranslator} + */ +export const translator = NodeTranslator.from({ + xmlName: 'w:abstractNum', + sdNodeOrKeyName: 'abstractNum', + type: NodeTranslator.translatorTypes.NODE, + attributes: [createIntegerAttributeHandler('w:abstractNumId')], + encode: (params, encodedAttrs) => { + const { nodes } = params; + const node = nodes[0]; + + const result = { + ...encodedAttrs, + ...encodeProperties(params, propertyTranslatorsByXmlName), + ...encodePropertiesByKey('w:lvl', 'levels', wLvlTranslator, params, node, 'ilvl'), + }; + + return result; + }, + decode: function (params) { + const currentValue = params.node.attrs?.['abstractNum']; + if (!currentValue) { + return undefined; + } + + const decodedAttrs = this.decodeAttributes({ node: { ...params.node, attrs: currentValue } }); + + const elements = [ + ...decodeProperties(params, propertyTranslatorsBySdName, currentValue), + ...decodePropertiesByKey('w:lvl', 'levels', wLvlTranslator, params, currentValue), + ]; + + const newNode = { + name: 'w:abstractNum', + type: 'element', + attributes: decodedAttrs, + elements: elements, + }; + + return newNode; + }, +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/abstractNum/abstractNum-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/abstractNum/abstractNum-translator.test.js new file mode 100644 index 0000000000..abc0868ee6 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/abstractNum/abstractNum-translator.test.js @@ -0,0 +1,168 @@ +import { describe, it, expect } from 'vitest'; +import { translator } from './abstractNum-translator.js'; +import { NodeTranslator } from '@translator'; + +describe('w:abstractNum translator', () => { + describe('config', () => { + it('should have correct properties', () => { + expect(translator.xmlName).toBe('w:abstractNum'); + expect(translator.sdNodeOrKeyName).toBe('abstractNum'); + expect(translator).toBeInstanceOf(NodeTranslator); + }); + }); + + describe('encode', () => { + it('should encode abstract numbering properties correctly', () => { + const xmlNode = { + name: 'w:abstractNum', + attributes: { 'w:abstractNumId': '1' }, + elements: [ + { name: 'w:nsid', attributes: { 'w:val': '10' } }, + { name: 'w:tmpl', attributes: { 'w:val': '20' } }, + { name: 'w:name', attributes: { 'w:val': 'List Numbering' } }, + { name: 'w:styleLink', attributes: { 'w:val': 'ListStyle' } }, + { name: 'w:numStyleLink', attributes: { 'w:val': 'ListNumStyle' } }, + { name: 'w:multiLevelType', attributes: { 'w:val': 'multilevel' } }, + { + name: 'w:lvl', + attributes: { 'w:ilvl': '1' }, + elements: [{ name: 'w:lvlText', attributes: { 'w:val': '%1.' } }], + }, + { + name: 'w:lvl', + attributes: { 'w:ilvl': '2' }, + elements: [{ name: 'w:lvlText', attributes: { 'w:val': '%2.' } }], + }, + { + name: 'w:lvl', + attributes: { 'w:ilvl': '3' }, + elements: [{ name: 'w:lvlText', attributes: { 'w:val': '%3.' } }], + }, + { + name: 'w:lvl', + attributes: { 'w:ilvl': '4' }, + elements: [{ name: 'w:lvlText', attributes: { 'w:val': '%4.' } }], + }, + { + name: 'w:lvl', + attributes: { 'w:ilvl': '5' }, + elements: [{ name: 'w:lvlText', attributes: { 'w:val': '%5.' } }], + }, + { + name: 'w:lvl', + attributes: { 'w:ilvl': '6' }, + elements: [{ name: 'w:lvlText', attributes: { 'w:val': '%6.' } }], + }, + { + name: 'w:lvl', + attributes: { 'w:ilvl': '7' }, + elements: [{ name: 'w:lvlText', attributes: { 'w:val': '%7.' } }], + }, + { + name: 'w:lvl', + attributes: { 'w:ilvl': '8' }, + elements: [{ name: 'w:lvlText', attributes: { 'w:val': '%8.' } }], + }, + { + name: 'w:lvl', + attributes: { 'w:ilvl': '9' }, + elements: [{ name: 'w:lvlText', attributes: { 'w:val': '%9.' } }], + }, + ], + }; + + const result = translator.encode({ nodes: [xmlNode] }); + + expect(result).toEqual({ + abstractNumId: 1, + nsid: 10, + tmpl: 20, + name: 'List Numbering', + styleLink: 'ListStyle', + numStyleLink: 'ListNumStyle', + multiLevelType: 'multilevel', + levels: { + 1: { + ilvl: 1, + lvlText: '%1.', + }, + 2: { + ilvl: 2, + lvlText: '%2.', + }, + 3: { + ilvl: 3, + lvlText: '%3.', + }, + 4: { + ilvl: 4, + lvlText: '%4.', + }, + 5: { + ilvl: 5, + lvlText: '%5.', + }, + 6: { + ilvl: 6, + lvlText: '%6.', + }, + 7: { + ilvl: 7, + lvlText: '%7.', + }, + 8: { + ilvl: 8, + lvlText: '%8.', + }, + 9: { + ilvl: 9, + lvlText: '%9.', + }, + }, + }); + }); + + it('should return attributes when no child properties are present', () => { + const xmlNode = { name: 'w:abstractNum', attributes: { 'w:abstractNumId': '1' }, elements: [] }; + const result = translator.encode({ nodes: [xmlNode] }); + expect(result).toEqual({ abstractNumId: 1 }); + }); + }); + + describe('decode', () => { + it('should decode an abstractNum object correctly', () => { + const superDocNode = { + attrs: { + abstractNum: { + abstractNumId: 1, + levels: { + 1: { + lvlText: '%1.', + }, + }, + }, + }, + }; + + const result = translator.decode({ node: superDocNode }); + + expect(result.name).toBe('w:abstractNum'); + expect(result.attributes).toEqual({ 'w:abstractNumId': '1' }); + expect(result.elements).toEqual( + expect.arrayContaining([ + { + name: 'w:lvl', + type: 'element', + attributes: {}, + elements: [{ name: 'w:lvlText', attributes: { 'w:val': '%1.' } }], + }, + ]), + ); + }); + + it('should return undefined if no abstractNum is present', () => { + const result = translator.decode({ node: { attrs: {} } }); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/abstractNum/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/abstractNum/index.js new file mode 100644 index 0000000000..2b817227cc --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/abstractNum/index.js @@ -0,0 +1 @@ +export * from './abstractNum-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/abstractNumId/abstractNumId-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/abstractNumId/abstractNumId-translator.js new file mode 100644 index 0000000000..8dc518711c --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/abstractNumId/abstractNumId-translator.js @@ -0,0 +1,9 @@ +import { NodeTranslator } from '@translator'; +import { createSingleIntegerPropertyHandler } from '@converter/v3/handlers/utils'; + +/** + * The NodeTranslator instance for the w:abstractNumId element. + * @type {import('@translator').NodeTranslator} + * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 694 + */ +export const translator = NodeTranslator.from(createSingleIntegerPropertyHandler('w:abstractNumId')); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/abstractNumId/abstractNumId-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/abstractNumId/abstractNumId-translator.test.js new file mode 100644 index 0000000000..94a20609bd --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/abstractNumId/abstractNumId-translator.test.js @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { translator } from './abstractNumId-translator.js'; + +describe('w:abstractNumId translator', () => { + describe('encode', () => { + it('extracts the w:val attribute', () => { + const result = translator.encode({ nodes: [{ attributes: { 'w:val': '1' } }] }); + expect(result).toBe(1); + }); + + it('returns undefined if w:val is missing', () => { + const result = translator.encode({ nodes: [{ attributes: {} }] }); + expect(result).toBeUndefined(); + }); + }); + + describe('decode', () => { + it('creates a w:abstractNumId element with the value in w:val', () => { + const { attributes: result } = translator.decode({ node: { attrs: { abstractNumId: 1 } } }); + expect(result).toEqual({ 'w:val': '1' }); + }); + + it('returns undefined if abstractNumId property is missing', () => { + const result = translator.decode({ node: { attrs: {} } }); + expect(result).toBeUndefined(); + }); + }); + + it('has correct metadata', () => { + expect(translator.xmlName).toBe('w:abstractNumId'); + expect(translator.sdNodeOrKeyName).toBe('abstractNumId'); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/abstractNumId/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/abstractNumId/index.js new file mode 100644 index 0000000000..649122394b --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/abstractNumId/index.js @@ -0,0 +1 @@ +export * from './abstractNumId-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/aliases/aliases-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/aliases/aliases-translator.js new file mode 100644 index 0000000000..665b3c20d8 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/aliases/aliases-translator.js @@ -0,0 +1,9 @@ +import { NodeTranslator } from '@translator'; +import { createSingleAttrPropertyHandler } from '../../utils.js'; + +/** + * The NodeTranslator instance for the aliases element. + * @type {import('@translator').NodeTranslator} + * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 618 + */ +export const translator = NodeTranslator.from(createSingleAttrPropertyHandler('w:aliases')); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/aliases/aliases-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/aliases/aliases-translator.test.js new file mode 100644 index 0000000000..afe0f2331b --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/aliases/aliases-translator.test.js @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { translator } from './aliases-translator.js'; + +describe('w:aliases translator', () => { + describe('encode', () => { + it('extracts the w:val attribute', () => { + const result = translator.encode({ nodes: [{ attributes: { 'w:val': 'Alias1,Alias2' } }] }); + expect(result).toBe('Alias1,Alias2'); + }); + + it('returns undefined if w:val is missing', () => { + const result = translator.encode({ nodes: [{ attributes: {} }] }); + expect(result).toBeUndefined(); + }); + }); + + describe('decode', () => { + it('creates a w:aliases element with the value in w:val', () => { + const { attributes: result } = translator.decode({ node: { attrs: { aliases: 'Alias1,Alias2' } } }); + expect(result).toEqual({ 'w:val': 'Alias1,Alias2' }); + }); + + it('returns undefined if aliases property is missing', () => { + const result = translator.decode({ node: { attrs: {} } }); + expect(result).toBeUndefined(); + }); + }); + + it('has correct metadata', () => { + expect(translator.xmlName).toBe('w:aliases'); + expect(translator.sdNodeOrKeyName).toBe('aliases'); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/aliases/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/aliases/index.js new file mode 100644 index 0000000000..a51a812f75 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/aliases/index.js @@ -0,0 +1 @@ +export * from './aliases-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/autoRedefine/autoRedefine-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/autoRedefine/autoRedefine-translator.js new file mode 100644 index 0000000000..8f45c41394 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/autoRedefine/autoRedefine-translator.js @@ -0,0 +1,9 @@ +import { NodeTranslator } from '@translator'; +import { createSingleBooleanPropertyHandler } from '@converter/v3/handlers/utils'; + +/** + * The NodeTranslator instance for the autoRedefine element. + * @type {import('@translator').NodeTranslator} + * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 619 + */ +export const translator = NodeTranslator.from(createSingleBooleanPropertyHandler('w:autoRedefine')); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/autoRedefine/autoRedefine-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/autoRedefine/autoRedefine-translator.test.js new file mode 100644 index 0000000000..eeba79db78 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/autoRedefine/autoRedefine-translator.test.js @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; + +import { translator } from './autoRedefine-translator.js'; +import { NodeTranslator } from '../../../node-translator/node-translator.js'; + +describe('w:autoRedefine translator (attribute)', () => { + it('exposes correct translator meta', () => { + expect(translator.xmlName).toBe('w:autoRedefine'); + expect(translator.sdNodeOrKeyName).toBe('autoRedefine'); + expect(typeof translator.encode).toBe('function'); + }); + + it('builds NodeTranslator instance', () => { + expect(translator).toBeInstanceOf(NodeTranslator); + expect(translator.xmlName).toBe('w:autoRedefine'); + expect(translator.sdNodeOrKeyName).toBe('autoRedefine'); + }); + + describe('encode', () => { + it('encodes with provided w:val as-is', () => { + const params = { nodes: [{ attributes: { 'w:val': '1' } }] }; + const out = translator.encode(params); + expect(out).toBe(true); + }); + + it('passes through raw attributes when missing encoded boolean', () => { + const params = { nodes: [{ attributes: {} }] }; + const out = translator.encode(params); + expect(out).toBe(true); + }); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/autoRedefine/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/autoRedefine/index.js new file mode 100644 index 0000000000..7af0778698 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/autoRedefine/index.js @@ -0,0 +1 @@ +export * from './autoRedefine-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/basedOn/basedOn-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/basedOn/basedOn-translator.js new file mode 100644 index 0000000000..8c9d26a13d --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/basedOn/basedOn-translator.js @@ -0,0 +1,9 @@ +import { NodeTranslator } from '@translator'; +import { createSingleAttrPropertyHandler } from '../../utils.js'; + +/** + * The NodeTranslator instance for the basedOn element. + * @type {import('@translator').NodeTranslator} + * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 391 + */ +export const translator = NodeTranslator.from(createSingleAttrPropertyHandler('w:basedOn')); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/basedOn/basedOn-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/basedOn/basedOn-translator.test.js new file mode 100644 index 0000000000..f12c3843f6 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/basedOn/basedOn-translator.test.js @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { translator } from './basedOn-translator.js'; + +describe('w:basedOn translator', () => { + describe('encode', () => { + it('extracts the w:val attribute', () => { + const result = translator.encode({ nodes: [{ attributes: { 'w:val': 'BaseStyle' } }] }); + expect(result).toBe('BaseStyle'); + }); + + it('returns undefined if w:val is missing', () => { + const result = translator.encode({ nodes: [{ attributes: {} }] }); + expect(result).toBeUndefined(); + }); + }); + + describe('decode', () => { + it('creates a w:basedOn element with the value in w:val', () => { + const { attributes: result } = translator.decode({ node: { attrs: { basedOn: 'BaseStyle' } } }); + expect(result).toEqual({ 'w:val': 'BaseStyle' }); + }); + + it('returns undefined if basedOn property is missing', () => { + const result = translator.decode({ node: { attrs: {} } }); + expect(result).toBeUndefined(); + }); + }); + + it('has correct metadata', () => { + expect(translator.xmlName).toBe('w:basedOn'); + expect(translator.sdNodeOrKeyName).toBe('basedOn'); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/basedOn/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/basedOn/index.js new file mode 100644 index 0000000000..b3e6f036dc --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/basedOn/index.js @@ -0,0 +1 @@ +export * from './basedOn-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/docDefaults/docDefaults-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/docDefaults/docDefaults-translator.js new file mode 100644 index 0000000000..fd33be575c --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/docDefaults/docDefaults-translator.js @@ -0,0 +1,89 @@ +import { NodeTranslator } from '@translator'; +import { translator as wPPrTranslator } from '../../w/pPr'; +import { translator as wRPrTranslator } from '../../w/rpr'; + +/** + * The NodeTranslator instance for the w:docDefaults element. + * @type {import('@translator').NodeTranslator} + */ +export const translator = NodeTranslator.from({ + xmlName: 'w:docDefaults', + sdNodeOrKeyName: 'docDefaults', + type: NodeTranslator.translatorTypes.NODE, + attributes: [], + encode: (params) => { + const { nodes } = params; + const node = nodes[0]; + const result = {}; + + [ + { + wrapperName: 'w:rPrDefault', + propertyName: 'runProperties', + translator: wRPrTranslator, + }, + { + wrapperName: 'w:pPrDefault', + propertyName: 'paragraphProperties', + translator: wPPrTranslator, + }, + ].forEach(({ wrapperName, propertyName, translator }) => { + const defaultElement = node.elements?.find((el) => el.name === wrapperName); + const propertyElement = defaultElement?.elements?.find((el) => el.name === wrapperName.replace('Default', '')); + if (propertyElement) { + const props = translator.encode({ ...params, nodes: [propertyElement] }); + if (props) { + result[propertyName] = props; + } + } + }); + + return Object.keys(result).length > 0 ? result : undefined; + }, + decode: function (params) { + const currentValue = params.node.attrs?.['docDefaults']; + if (!currentValue) { + return undefined; + } + + const elements = []; + + [ + { + wrapperName: 'w:rPrDefault', + propertyName: 'runProperties', + translator: wRPrTranslator, + }, + { + wrapperName: 'w:pPrDefault', + propertyName: 'paragraphProperties', + translator: wPPrTranslator, + }, + ].forEach(({ wrapperName, propertyName, translator }) => { + const propertyValue = currentValue[propertyName]; + if (propertyValue) { + const decodedProperty = translator.decode({ ...params, node: { attrs: { [propertyName]: propertyValue } } }); + if (decodedProperty) { + elements.push({ + name: wrapperName, + type: 'element', + elements: [decodedProperty], + }); + } + } + }); + + if (elements.length === 0) { + return undefined; + } + + const newNode = { + name: 'w:docDefaults', + type: 'element', + attributes: {}, + elements: elements, + }; + + return newNode; + }, +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/docDefaults/docDefaults-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/docDefaults/docDefaults-translator.test.js new file mode 100644 index 0000000000..d28dd2ba50 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/docDefaults/docDefaults-translator.test.js @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest'; +import { translator } from './docDefaults-translator.js'; +import { NodeTranslator } from '@translator'; + +describe('w:docDefaults translator', () => { + describe('config', () => { + it('should have correct properties', () => { + expect(translator.xmlName).toBe('w:docDefaults'); + expect(translator.sdNodeOrKeyName).toBe('docDefaults'); + expect(translator).toBeInstanceOf(NodeTranslator); + }); + }); + + describe('encode', () => { + it('should encode default run and paragraph properties', () => { + const xmlNode = { + name: 'w:docDefaults', + elements: [ + { + name: 'w:rPrDefault', + elements: [{ name: 'w:rPr', elements: [{ name: 'w:b' }] }], + }, + { + name: 'w:pPrDefault', + elements: [{ name: 'w:pPr', elements: [{ name: 'w:keepNext' }] }], + }, + ], + }; + + const result = translator.encode({ nodes: [xmlNode] }); + + expect(result).toEqual({ + runProperties: { bold: true }, + paragraphProperties: { keepNext: true }, + }); + }); + + it('should return undefined if no default properties are present', () => { + const xmlNode = { name: 'w:docDefaults', elements: [] }; + const result = translator.encode({ nodes: [xmlNode] }); + expect(result).toBeUndefined(); + }); + }); + + describe('decode', () => { + it('should decode docDefaults into wrapped properties', () => { + const superDocNode = { + attrs: { + docDefaults: { + runProperties: { bold: true }, + paragraphProperties: { keepNext: true }, + }, + }, + }; + + const result = translator.decode({ node: superDocNode }); + + expect(result.name).toBe('w:docDefaults'); + expect(result.elements).toEqual( + expect.arrayContaining([ + { + name: 'w:rPrDefault', + type: 'element', + elements: [{ name: 'w:rPr', type: 'element', attributes: {}, elements: [{ name: 'w:b', attributes: {} }] }], + }, + { + name: 'w:pPrDefault', + type: 'element', + elements: [ + { name: 'w:pPr', type: 'element', attributes: {}, elements: [{ name: 'w:keepNext', attributes: {} }] }, + ], + }, + ]), + ); + }); + + it('should return undefined if no docDefaults are present', () => { + const result = translator.decode({ node: { attrs: {} } }); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/docDefaults/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/docDefaults/index.js new file mode 100644 index 0000000000..4e956dc2c9 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/docDefaults/index.js @@ -0,0 +1 @@ +export * from './docDefaults-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/hidden/hidden-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/hidden/hidden-translator.js index c3798483c3..8717c6469c 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/hidden/hidden-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/hidden/hidden-translator.js @@ -1,14 +1,9 @@ import { NodeTranslator } from '@translator'; -import { parseBoolean } from '@converter/v3/handlers/utils'; +import { createSingleBooleanPropertyHandler } from '@converter/v3/handlers/utils'; /** * The NodeTranslator instance for the hidden element. * @type {import('@translator').NodeTranslator} * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 405 */ -export const translator = NodeTranslator.from({ - xmlName: 'w:hidden', - sdNodeOrKeyName: 'hidden', - encode: ({ nodes }) => parseBoolean(nodes[0].attributes?.['w:val'] ?? '1'), - decode: ({ node }) => (node.attrs.hidden ? { attributes: {} } : undefined), -}); +export const translator = NodeTranslator.from(createSingleBooleanPropertyHandler('w:hidden')); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/hidden/hidden-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/hidden/hidden-translator.test.js index 07bc570866..d1fea6c68f 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/hidden/hidden-translator.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/hidden/hidden-translator.test.js @@ -1,35 +1,32 @@ import { describe, it, expect } from 'vitest'; + import { translator } from './hidden-translator.js'; +import { NodeTranslator } from '../../../node-translator/node-translator.js'; -describe('w:hidden translator', () => { - describe('encode', () => { - it('returns true for "1", "true", or missing w:val', () => { - expect(translator.encode({ nodes: [{ attributes: { 'w:val': '1' } }] })).toBe(true); - expect(translator.encode({ nodes: [{ attributes: { 'w:val': 'true' } }] })).toBe(true); - expect(translator.encode({ nodes: [{ attributes: {} }] })).toBe(true); // defaults to '1' - }); +describe('w:hidden translator (attribute)', () => { + it('exposes correct translator meta', () => { + expect(translator.xmlName).toBe('w:hidden'); + expect(translator.sdNodeOrKeyName).toBe('hidden'); + expect(typeof translator.encode).toBe('function'); + }); - it('returns false for other values', () => { - expect(translator.encode({ nodes: [{ attributes: { 'w:val': '0' } }] })).toBe(false); - expect(translator.encode({ nodes: [{ attributes: { 'w:val': 'false' } }] })).toBe(false); - expect(translator.encode({ nodes: [{ attributes: { 'w:val': 'any other string' } }] })).toBe(false); - }); + it('builds NodeTranslator instance', () => { + expect(translator).toBeInstanceOf(NodeTranslator); + expect(translator.xmlName).toBe('w:hidden'); + expect(translator.sdNodeOrKeyName).toBe('hidden'); }); - describe('decode', () => { - it('creates a w:hidden element if hidden is true', () => { - const { attributes: result } = translator.decode({ node: { attrs: { hidden: true } } }); - expect(result).toEqual({}); + describe('encode', () => { + it('encodes with provided w:val as-is', () => { + const params = { nodes: [{ attributes: { 'w:val': '1' } }] }; + const out = translator.encode(params); + expect(out).toBe(true); }); - it('returns undefined if hidden is false or missing', () => { - expect(translator.decode({ node: { attrs: { hidden: false } } })).toBeUndefined(); - expect(translator.decode({ node: { attrs: {} } })).toBeUndefined(); + it('passes through raw attributes when missing encoded boolean', () => { + const params = { nodes: [{ attributes: {} }] }; + const out = translator.encode(params); + expect(out).toBe(true); }); }); - - it('has correct metadata', () => { - expect(translator.xmlName).toBe('w:hidden'); - expect(translator.sdNodeOrKeyName).toBe('hidden'); - }); }); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/isLgl/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/isLgl/index.js new file mode 100644 index 0000000000..ead93fe626 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/isLgl/index.js @@ -0,0 +1 @@ +export * from './isLgl-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/isLgl/isLgl-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/isLgl/isLgl-translator.js new file mode 100644 index 0000000000..c37d0be098 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/isLgl/isLgl-translator.js @@ -0,0 +1,9 @@ +import { NodeTranslator } from '@translator'; +import { createSingleBooleanPropertyHandler } from '@converter/v3/handlers/utils'; + +/** + * The NodeTranslator instance for the w:isLgl element. + * @type {import('@translator').NodeTranslator} + * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 697 + */ +export const translator = NodeTranslator.from(createSingleBooleanPropertyHandler('w:isLgl')); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/isLgl/isLgl-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/isLgl/isLgl-translator.test.js new file mode 100644 index 0000000000..8bfd41ddd3 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/isLgl/isLgl-translator.test.js @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; + +import { translator } from './isLgl-translator.js'; +import { NodeTranslator } from '../../../node-translator/node-translator.js'; + +describe('w:isLgl translator (attribute)', () => { + it('exposes correct translator meta', () => { + expect(translator.xmlName).toBe('w:isLgl'); + expect(translator.sdNodeOrKeyName).toBe('isLgl'); + expect(typeof translator.encode).toBe('function'); + }); + + it('builds NodeTranslator instance', () => { + expect(translator).toBeInstanceOf(NodeTranslator); + expect(translator.xmlName).toBe('w:isLgl'); + expect(translator.sdNodeOrKeyName).toBe('isLgl'); + }); + + describe('encode', () => { + it('encodes with provided w:val as-is', () => { + const params = { nodes: [{ attributes: { 'w:val': '1' } }] }; + const out = translator.encode(params); + expect(out).toBe(true); + }); + + it('passes through raw attributes when missing encoded boolean', () => { + const params = { nodes: [{ attributes: {} }] }; + const out = translator.encode(params); + expect(out).toBe(true); + }); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/latentStyles/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/latentStyles/index.js new file mode 100644 index 0000000000..b4dda4411d --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/latentStyles/index.js @@ -0,0 +1 @@ +export * from './latentStyles-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/latentStyles/latentStyles-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/latentStyles/latentStyles-translator.js new file mode 100644 index 0000000000..cfd72cff34 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/latentStyles/latentStyles-translator.js @@ -0,0 +1,65 @@ +// @ts-check +import { NodeTranslator } from '@translator'; +import { + encodePropertiesByKey, + decodePropertiesByKey, + createBooleanAttributeHandler, +} from '@converter/v3/handlers/utils.js'; +import { translator as wLsdExceptionTranslator } from '../lsdException'; + +/** + * The NodeTranslator instance for the w:latentStyles element. + * @type {import('@translator').NodeTranslator} + */ +export const translator = NodeTranslator.from({ + xmlName: 'w:latentStyles', + sdNodeOrKeyName: 'latentStyles', + type: NodeTranslator.translatorTypes.NODE, + attributes: [ + createBooleanAttributeHandler('w:defLockedState'), + createBooleanAttributeHandler('w:defUIPriority'), + createBooleanAttributeHandler('w:defSemiHidden'), + createBooleanAttributeHandler('w:defUnhideWhenUsed'), + createBooleanAttributeHandler('w:defQFormat'), + ], + encode: (params, encodedAttrs) => { + const { nodes } = params; + const node = nodes[0]; + + const lsdExceptions = encodePropertiesByKey( + 'w:lsdException', + 'lsdExceptions', + wLsdExceptionTranslator, + params, + node, + 'name', + ); + + return { ...lsdExceptions, ...encodedAttrs }; + }, + decode: function (params) { + // @ts-expect-error The decode function is bound to the NodeTranslator instance. + const decodedAttrs = this.decodeAttributes({ + node: { ...params.node, attrs: params.node.attrs.latentStyles || {} }, + }); + const currentValue = params.node.attrs?.latentStyles; + if (!currentValue) { + return undefined; + } + const elements = decodePropertiesByKey( + 'w:lsdException', + 'lsdExceptions', + wLsdExceptionTranslator, + params, + currentValue, + ); + + const newNode = { + name: 'w:latentStyles', + attributes: decodedAttrs, + elements: elements, + }; + + return newNode; + }, +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/latentStyles/latentStyles-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/latentStyles/latentStyles-translator.test.js new file mode 100644 index 0000000000..a489f923ce --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/latentStyles/latentStyles-translator.test.js @@ -0,0 +1,118 @@ +import { describe, it, expect } from 'vitest'; +import { translator } from './latentStyles-translator.js'; +import { NodeTranslator } from '@translator'; + +describe('w:latentStyles translator', () => { + describe('config', () => { + it('should have correct properties', () => { + expect(translator.xmlName).toBe('w:latentStyles'); + expect(translator.sdNodeOrKeyName).toBe('latentStyles'); + expect(translator).toBeInstanceOf(NodeTranslator); + }); + }); + + describe('encode', () => { + it('should encode latentStyles attributes and exceptions', () => { + const xmlNode = { + name: 'w:latentStyles', + attributes: { + 'w:defLockedState': '1', + 'w:defUIPriority': '1', + 'w:defSemiHidden': '0', + 'w:defUnhideWhenUsed': '1', + 'w:defQFormat': '1', + }, + elements: [ + { + name: 'w:lsdException', + attributes: { + 'w:name': 'NoList', + 'w:locked': '1', + 'w:qFormat': '1', + 'w:semiHidden': '0', + 'w:unhideWhenUsed': '1', + 'w:uiPriority': '99', + }, + }, + ], + }; + + const result = translator.encode({ nodes: [xmlNode] }); + + expect(result).toEqual({ + lsdExceptions: { + NoList: { + name: 'NoList', + locked: true, + qFormat: true, + semiHidden: false, + unhideWhenUsed: true, + uiPriority: 99, + }, + }, + defLockedState: true, + defUIPriority: true, + defSemiHidden: false, + defUnhideWhenUsed: true, + defQFormat: true, + }); + }); + }); + + describe('decode', () => { + it('should decode latentStyles into OOXML elements', () => { + const superDocNode = { + attrs: { + latentStyles: { + defLockedState: true, + defUIPriority: true, + defSemiHidden: false, + defUnhideWhenUsed: true, + defQFormat: true, + lsdExceptions: { + NoList: { + name: 'NoList', + locked: true, + qFormat: true, + semiHidden: false, + unhideWhenUsed: true, + uiPriority: 99, + }, + }, + }, + }, + }; + + const result = translator.decode({ node: superDocNode }); + + expect(result).toEqual({ + name: 'w:latentStyles', + attributes: { + 'w:defLockedState': '1', + 'w:defUIPriority': '1', + 'w:defSemiHidden': '0', + 'w:defUnhideWhenUsed': '1', + 'w:defQFormat': '1', + }, + elements: [ + { + name: 'w:lsdException', + attributes: { + 'w:name': 'NoList', + 'w:locked': '1', + 'w:qFormat': '1', + 'w:semiHidden': '0', + 'w:unhideWhenUsed': '1', + 'w:uiPriority': '99', + }, + }, + ], + }); + }); + + it('should return undefined if no latentStyles are present', () => { + const result = translator.decode({ node: { attrs: {} } }); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/legacy/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/legacy/index.js new file mode 100644 index 0000000000..20b38cbf9c --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/legacy/index.js @@ -0,0 +1 @@ +export * from './legacy-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/legacy/legacy-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/legacy/legacy-translator.js new file mode 100644 index 0000000000..6bf4ff9d44 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/legacy/legacy-translator.js @@ -0,0 +1,23 @@ +import { NodeTranslator } from '@translator'; +import { createIntegerAttributeHandler, createBooleanAttributeHandler } from '@converter/v3/handlers/utils'; + +/** + * The NodeTranslator instance for the w:legacy element. + * @type {import('@translator').NodeTranslator} + */ +export const translator = NodeTranslator.from({ + xmlName: 'w:legacy', + sdNodeOrKeyName: 'legacy', + attributes: [ + createBooleanAttributeHandler('w:legacy'), + createIntegerAttributeHandler('w:legacySpace'), + createIntegerAttributeHandler('w:legacyIndent'), + ], + encode: (_, encodedAttrs) => { + return encodedAttrs; + }, + decode: function ({ node }) { + const decodedAttrs = this.decodeAttributes({ node: { ...node, attrs: node.attrs['legacy'] || {} } }); + return Object.keys(decodedAttrs).length > 0 ? { attributes: decodedAttrs } : undefined; + }, +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/legacy/legacy-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/legacy/legacy-translator.test.js new file mode 100644 index 0000000000..1af698ff64 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/legacy/legacy-translator.test.js @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest'; +import { translator } from './legacy-translator.js'; + +describe('legacy-translator', () => { + describe('decode', () => { + it('should return the decoded attributes from a node', () => { + const node = { + attrs: { + legacy: { + legacy: true, + legacySpace: 120, + legacyIndent: 240, + }, + }, + }; + expect(translator.decode({ node })).toEqual({ + attributes: { + 'w:legacy': '1', + 'w:legacySpace': '120', + 'w:legacyIndent': '240', + }, + }); + }); + + it('should return only the existing attributes from a node', () => { + const node = { + attrs: { + legacy: { + legacySpace: 120, + }, + }, + }; + expect(translator.decode({ node })).toEqual({ + attributes: { + 'w:legacySpace': '120', + }, + }); + }); + + it('should return undefined if "legacy" attribute is not present in the node', () => { + const node = { + attrs: {}, + }; + expect(translator.decode({ node })).toBeUndefined(); + }); + + it('should return undefined if "legacy" attribute is an empty object', () => { + const node = { + attrs: { + legacy: {}, + }, + }; + expect(translator.decode({ node })).toBeUndefined(); + }); + + it('should handle numeric string values for integer attributes', () => { + const node = { + attrs: { + legacy: { + legacySpace: '120', + legacyIndent: '240', + }, + }, + }; + expect(translator.decode({ node })).toEqual({ + attributes: { + 'w:legacySpace': '120', + 'w:legacyIndent': '240', + }, + }); + }); + }); + + describe('encode', () => { + it('should return the encoded attributes for a w:legacy node', () => { + const sdNode = { + attributes: { + 'w:legacy': '1', + 'w:legacySpace': '120', + 'w:legacyIndent': '240', + }, + }; + expect(translator.encode({ nodes: [sdNode] })).toEqual({ + legacy: true, + legacySpace: 120, + legacyIndent: 240, + }); + }); + + it('should return only the existing attributes for a w:legacy node', () => { + const sdNode = { + attributes: { + 'w:legacySpace': '120', + }, + }; + expect(translator.encode({ nodes: [sdNode] })).toEqual({ + legacySpace: 120, + }); + }); + + it('should return an empty object for a w:legacy node if no attributes are passed', () => { + const sdNode = { + attributes: {}, + }; + expect(translator.encode({ nodes: [sdNode] })).toEqual({}); + }); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/link/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/link/index.js new file mode 100644 index 0000000000..43652ac350 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/link/index.js @@ -0,0 +1 @@ +export * from './link-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/link/link-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/link/link-translator.js new file mode 100644 index 0000000000..fa5afcf6d4 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/link/link-translator.js @@ -0,0 +1,9 @@ +import { NodeTranslator } from '@translator'; +import { createSingleAttrPropertyHandler } from '../../utils.js'; + +/** + * The NodeTranslator instance for the link element. + * @type {import('@translator').NodeTranslator} + * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 628 + */ +export const translator = NodeTranslator.from(createSingleAttrPropertyHandler('w:link')); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/link/link-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/link/link-translator.test.js new file mode 100644 index 0000000000..9b8b741495 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/link/link-translator.test.js @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { translator } from './link-translator.js'; + +describe('w:link translator', () => { + describe('encode', () => { + it('extracts the w:val attribute', () => { + const result = translator.encode({ nodes: [{ attributes: { 'w:val': 'LinkedStyle' } }] }); + expect(result).toBe('LinkedStyle'); + }); + + it('returns undefined if w:val is missing', () => { + const result = translator.encode({ nodes: [{ attributes: {} }] }); + expect(result).toBeUndefined(); + }); + }); + + describe('decode', () => { + it('creates a w:link element with the value in w:val', () => { + const { attributes: result } = translator.decode({ node: { attrs: { link: 'LinkedStyle' } } }); + expect(result).toEqual({ 'w:val': 'LinkedStyle' }); + }); + + it('returns undefined if link property is missing', () => { + const result = translator.decode({ node: { attrs: {} } }); + expect(result).toBeUndefined(); + }); + }); + + it('has correct metadata', () => { + expect(translator.xmlName).toBe('w:link'); + expect(translator.sdNodeOrKeyName).toBe('link'); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/locked/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/locked/index.js new file mode 100644 index 0000000000..1f0e05974b --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/locked/index.js @@ -0,0 +1 @@ +export * from './locked-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/locked/locked-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/locked/locked-translator.js new file mode 100644 index 0000000000..88985d3c06 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/locked/locked-translator.js @@ -0,0 +1,9 @@ +import { NodeTranslator } from '@translator'; +import { createSingleBooleanPropertyHandler } from '@converter/v3/handlers/utils'; + +/** + * The NodeTranslator instance for the locked element. + * @type {import('@translator').NodeTranslator} + * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 629 + */ +export const translator = NodeTranslator.from(createSingleBooleanPropertyHandler('w:locked')); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/locked/locked-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/locked/locked-translator.test.js new file mode 100644 index 0000000000..e525c0e8b0 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/locked/locked-translator.test.js @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; + +import { translator } from './locked-translator.js'; +import { NodeTranslator } from '../../../node-translator/node-translator.js'; + +describe('w:locked translator (attribute)', () => { + it('exposes correct translator meta', () => { + expect(translator.xmlName).toBe('w:locked'); + expect(translator.sdNodeOrKeyName).toBe('locked'); + expect(typeof translator.encode).toBe('function'); + }); + + it('builds NodeTranslator instance', () => { + expect(translator).toBeInstanceOf(NodeTranslator); + expect(translator.xmlName).toBe('w:locked'); + expect(translator.sdNodeOrKeyName).toBe('locked'); + }); + + describe('encode', () => { + it('encodes with provided w:val as-is', () => { + const params = { nodes: [{ attributes: { 'w:val': '1' } }] }; + const out = translator.encode(params); + expect(out).toBe(true); + }); + + it('passes through raw attributes when missing encoded boolean', () => { + const params = { nodes: [{ attributes: {} }] }; + const out = translator.encode(params); + expect(out).toBe(true); + }); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/lsdException/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/lsdException/index.js new file mode 100644 index 0000000000..895f9af81e --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/lsdException/index.js @@ -0,0 +1 @@ +export * from './lsdException-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/lsdException/lsdException-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/lsdException/lsdException-translator.js new file mode 100644 index 0000000000..e86b531a6a --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/lsdException/lsdException-translator.js @@ -0,0 +1,31 @@ +import { NodeTranslator } from '@translator'; +import { + createAttributeHandler, + createIntegerAttributeHandler, + createBooleanAttributeHandler, +} from '@converter/v3/handlers/utils.js'; + +/** + * The NodeTranslator instance for the w:lsdException element. + * @type {import('@translator').NodeTranslator} + */ +export const translator = NodeTranslator.from({ + xmlName: 'w:lsdException', + sdNodeOrKeyName: 'lsdException', + type: NodeTranslator.translatorTypes.NODE, + attributes: [ + createAttributeHandler('w:name'), + createBooleanAttributeHandler('w:locked'), + createBooleanAttributeHandler('w:qFormat'), + createBooleanAttributeHandler('w:semiHidden'), + createBooleanAttributeHandler('w:unhideWhenUsed'), + createIntegerAttributeHandler('w:uiPriority'), + ], + encode: (_, encodedAttrs) => { + return encodedAttrs; + }, + decode: function ({ node }) { + const decodedAttrs = this.decodeAttributes({ node: { ...node, attrs: node.attrs['lsdException'] || {} } }); + return Object.keys(decodedAttrs).length > 0 ? { name: 'w:lsdException', attributes: decodedAttrs } : undefined; + }, +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/lsdException/lsdException-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/lsdException/lsdException-translator.test.js new file mode 100644 index 0000000000..929cbd73d2 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/lsdException/lsdException-translator.test.js @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import { translator } from './lsdException-translator.js'; +import { NodeTranslator } from '@translator'; + +describe('w:lsdException translator', () => { + describe('config', () => { + it('should have correct properties', () => { + expect(translator.xmlName).toBe('w:lsdException'); + expect(translator.sdNodeOrKeyName).toBe('lsdException'); + expect(translator).toBeInstanceOf(NodeTranslator); + }); + }); + + describe('encode', () => { + it('should encode lsdException attributes correctly', () => { + const xmlNode = { + name: 'w:lsdException', + attributes: { + 'w:name': 'NoList', + 'w:locked': '1', + 'w:qFormat': '1', + 'w:semiHidden': '0', + 'w:unhideWhenUsed': '1', + 'w:uiPriority': '99', + }, + }; + + const result = translator.encode({ nodes: [xmlNode] }); + + expect(result).toEqual({ + name: 'NoList', + locked: true, + qFormat: true, + semiHidden: false, + unhideWhenUsed: true, + uiPriority: 99, + }); + }); + + it('should return an empty object when no attributes are present', () => { + const xmlNode = { name: 'w:lsdException', attributes: {} }; + const result = translator.encode({ nodes: [xmlNode] }); + expect(result).toEqual({}); + }); + }); + + describe('decode', () => { + it('should decode a lsdException object correctly', () => { + const superDocNode = { + attrs: { + lsdException: { + name: 'NoList', + locked: true, + qFormat: true, + semiHidden: false, + unhideWhenUsed: true, + uiPriority: 99, + }, + }, + }; + + const result = translator.decode({ node: superDocNode }); + + expect(result).toEqual({ + name: 'w:lsdException', + attributes: { + 'w:name': 'NoList', + 'w:locked': '1', + 'w:qFormat': '1', + 'w:semiHidden': '0', + 'w:unhideWhenUsed': '1', + 'w:uiPriority': '99', + }, + }); + }); + + it('should return undefined if no lsdException is present', () => { + const result = translator.decode({ node: { attrs: {} } }); + expect(result).toBeUndefined(); + }); + + it('should return undefined if lsdException is empty', () => { + const result = translator.decode({ node: { attrs: { lsdException: {} } } }); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/lvl/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvl/index.js new file mode 100644 index 0000000000..44f8208f5e --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvl/index.js @@ -0,0 +1 @@ +export * from './lvl-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/lvl/lvl-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvl/lvl-translator.js new file mode 100644 index 0000000000..557d8aabb6 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvl/lvl-translator.js @@ -0,0 +1,49 @@ +// @ts-check +import { NodeTranslator } from '@translator'; +import { + createNestedPropertiesTranslator, + createIntegerAttributeHandler, + createBooleanAttributeHandler, +} from '@converter/v3/handlers/utils.js'; +import { translator as wLvlStartTranslator } from '../../w/start/lvlStart-translator.js'; +import { translator as wLvlPicBulletId } from '../../w/lvlPicBulletId/lvlPicBulletId-translator.js'; +import { translator as wIsLglTranslator } from '../../w/isLgl/isLgl-translator.js'; +import { translator as wPStyleTranslator } from '../../w/pStyle/pStyle-translator.js'; +import { translator as wSuffTranslator } from '../../w/suff/suff-translator.js'; +import { translator as wLvlTextTranslator } from '../../w/lvlText/lvlText-translator.js'; +import { translator as wLvlJcTranslator } from '../../w/lvlJc/lvlJc-translator.js'; +import { translator as wNumFmtTranslator } from '../../w/numFmt/numFmt-translator.js'; +import { translator as wLegacyTranslator } from '../../w/legacy/legacy-translator.js'; +import { translator as wPPrTranslator } from '../../w/pPr'; +import { translator as wRPrTranslator } from '../../w/rpr'; + +// Property translators for w:lvl child elements +// Each translator handles a specific property +/** @type {import('@translator').NodeTranslator[]} */ +const propertyTranslators = [ + wLvlStartTranslator, + wLvlPicBulletId, + wIsLglTranslator, + wPStyleTranslator, + wSuffTranslator, + wLvlTextTranslator, + wLvlJcTranslator, + wNumFmtTranslator, + wLegacyTranslator, + wPPrTranslator, + wRPrTranslator, +]; + +const attributeHandlers = [ + createIntegerAttributeHandler('w:ilvl'), + createIntegerAttributeHandler('w:tplc'), + createBooleanAttributeHandler('w:tentative'), +]; + +/** + * The NodeTranslator instance for the w:lvl element. + * @type {import('@translator').NodeTranslator} + */ +export const translator = NodeTranslator.from( + createNestedPropertiesTranslator('w:lvl', 'lvl', propertyTranslators, {}, attributeHandlers), +); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/lvl/lvl-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvl/lvl-translator.test.js new file mode 100644 index 0000000000..82e38f1d63 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvl/lvl-translator.test.js @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest'; +import { translator } from './lvl-translator.js'; +import { NodeTranslator } from '@translator'; + +describe('w:lvl translator', () => { + describe('config', () => { + it('should have correct properties', () => { + expect(translator.xmlName).toBe('w:lvl'); + expect(translator.sdNodeOrKeyName).toBe('lvl'); + expect(translator).toBeInstanceOf(NodeTranslator); + }); + }); + + describe('encode', () => { + it('should encode nested level properties correctly', () => { + const xmlNode = { + name: 'w:lvl', + elements: [ + { name: 'w:start', attributes: { 'w:val': '1' } }, + { name: 'w:lvlPicBulletId', attributes: { 'w:val': '2' } }, + { name: 'w:isLgl' }, + { name: 'w:pStyle', attributes: { 'w:val': 'Heading1' } }, + { name: 'w:suff', attributes: { 'w:val': 'space' } }, + { name: 'w:lvlText', attributes: { 'w:val': '%1.' } }, + { name: 'w:lvlJc', attributes: { 'w:val': 'center' } }, + { name: 'w:numFmt', attributes: { 'w:val': 'decimal', 'w:format': '1.' } }, + { name: 'w:legacy', attributes: { 'w:legacy': '1', 'w:legacySpace': '120', 'w:legacyIndent': '240' } }, + { name: 'w:pPr', elements: [{ name: 'w:keepNext' }] }, + { name: 'w:rPr', elements: [{ name: 'w:b' }] }, + ], + }; + + const result = translator.encode({ nodes: [xmlNode] }); + + expect(result).toEqual({ + start: 1, + lvlPicBulletId: 2, + isLgl: true, + styleId: 'Heading1', + suff: 'space', + lvlText: '%1.', + lvlJc: 'center', + numFmt: { val: 'decimal', format: '1.' }, + legacy: { legacy: true, legacySpace: 120, legacyIndent: 240 }, + paragraphProperties: { keepNext: true }, + runProperties: { bold: true }, + }); + }); + + it('should return undefined if no child properties are present', () => { + const xmlNode = { name: 'w:lvl', elements: [] }; + const result = translator.encode({ nodes: [xmlNode] }); + expect(result).toBeUndefined(); + }); + }); + + describe('decode', () => { + it('should decode a lvl object correctly', () => { + const superDocNode = { + attrs: { + lvl: { + start: 1, + lvlPicBulletId: 2, + isLgl: true, + styleId: 'Heading1', + suff: 'space', + lvlText: '%1.', + lvlJc: 'center', + numFmt: { val: 'decimal', format: '1.' }, + legacy: { legacy: true, legacySpace: 120, legacyIndent: 240 }, + paragraphProperties: { keepNext: true }, + runProperties: { bold: true }, + }, + }, + }; + + const result = translator.decode({ node: superDocNode }); + + expect(result.name).toBe('w:lvl'); + expect(result.elements).toEqual( + expect.arrayContaining([ + { name: 'w:start', attributes: { 'w:val': '1' } }, + { name: 'w:lvlPicBulletId', attributes: { 'w:val': '2' } }, + { name: 'w:isLgl', attributes: {} }, + { name: 'w:pStyle', attributes: { 'w:val': 'Heading1' } }, + { name: 'w:suff', attributes: { 'w:val': 'space' } }, + { name: 'w:lvlText', attributes: { 'w:val': '%1.' } }, + { name: 'w:lvlJc', attributes: { 'w:val': 'center' } }, + { name: 'w:numFmt', attributes: { 'w:val': 'decimal', 'w:format': '1.' } }, + { + name: 'w:legacy', + attributes: { 'w:legacy': '1', 'w:legacySpace': '120', 'w:legacyIndent': '240' }, + }, + { name: 'w:pPr', type: 'element', attributes: {}, elements: [{ name: 'w:keepNext', attributes: {} }] }, + { name: 'w:rPr', type: 'element', attributes: {}, elements: [{ name: 'w:b', attributes: {} }] }, + ]), + ); + }); + + it('should return undefined if no lvl properties are present', () => { + const result = translator.decode({ node: { attrs: {} } }); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlJc/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlJc/index.js new file mode 100644 index 0000000000..5d579da04e --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlJc/index.js @@ -0,0 +1 @@ +export * from './lvlJc-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlJc/lvlJc-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlJc/lvlJc-translator.js new file mode 100644 index 0000000000..67fabc8e4e --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlJc/lvlJc-translator.js @@ -0,0 +1,9 @@ +import { NodeTranslator } from '@translator'; +import { createSingleAttrPropertyHandler } from '../../utils.js'; + +/** + * The NodeTranslator instance for the lvlJc element. + * @type {import('@translator').NodeTranslator} + * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 704 + */ +export const translator = NodeTranslator.from(createSingleAttrPropertyHandler('w:lvlJc')); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlJc/lvlJc-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlJc/lvlJc-translator.test.js new file mode 100644 index 0000000000..85e5b63eae --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlJc/lvlJc-translator.test.js @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { translator } from './lvlJc-translator.js'; + +describe('w:lvlJc translator', () => { + describe('encode', () => { + it('extracts the w:val attribute', () => { + const result = translator.encode({ nodes: [{ attributes: { 'w:val': 'center' } }] }); + expect(result).toBe('center'); + }); + + it('returns undefined if w:val is missing', () => { + const result = translator.encode({ nodes: [{ attributes: {} }] }); + expect(result).toBeUndefined(); + }); + }); + + describe('decode', () => { + it('creates a w:lvlJc element with the value in w:val', () => { + const { attributes: result } = translator.decode({ node: { attrs: { lvlJc: 'center' } } }); + expect(result).toEqual({ 'w:val': 'center' }); + }); + + it('returns undefined if lvlJc property is missing', () => { + const result = translator.decode({ node: { attrs: {} } }); + expect(result).toBeUndefined(); + }); + }); + + it('has correct metadata', () => { + expect(translator.xmlName).toBe('w:lvlJc'); + expect(translator.sdNodeOrKeyName).toBe('lvlJc'); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlOverride/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlOverride/index.js new file mode 100644 index 0000000000..840b86452c --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlOverride/index.js @@ -0,0 +1 @@ +export * from './lvlOverride-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlOverride/lvlOverride-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlOverride/lvlOverride-translator.js new file mode 100644 index 0000000000..db7bde7003 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlOverride/lvlOverride-translator.js @@ -0,0 +1,20 @@ +// @ts-check +import { NodeTranslator } from '@translator'; +import { createNestedPropertiesTranslator, createIntegerAttributeHandler } from '@converter/v3/handlers/utils.js'; +import { translator as wStartOverrideTranslator } from '../../w/startOverride'; +import { translator as wLvlTranslator } from '../../w/lvl'; + +// Property translators for w:lvlOverride child elements +// Each translator handles a specific property +/** @type {import('@translator').NodeTranslator[]} */ +const propertyTranslators = [wStartOverrideTranslator, wLvlTranslator]; + +const attributeHandlers = [createIntegerAttributeHandler('w:ilvl')]; + +/** + * The NodeTranslator instance for the w:lvlOverride element. + * @type {import('@translator').NodeTranslator} + */ +export const translator = NodeTranslator.from( + createNestedPropertiesTranslator('w:lvlOverride', 'lvlOverride', propertyTranslators, {}, attributeHandlers), +); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlOverride/lvlOverride-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlOverride/lvlOverride-translator.test.js new file mode 100644 index 0000000000..1f08f17b25 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlOverride/lvlOverride-translator.test.js @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest'; +import { translator } from './lvlOverride-translator.js'; +import { NodeTranslator } from '@translator'; + +describe('w:lvlOverride translator', () => { + describe('config', () => { + it('should have correct properties', () => { + expect(translator.xmlName).toBe('w:lvlOverride'); + expect(translator.sdNodeOrKeyName).toBe('lvlOverride'); + expect(translator).toBeInstanceOf(NodeTranslator); + }); + }); + + describe('encode', () => { + it('should encode nested level override properties correctly', () => { + const xmlNode = { + name: 'w:lvlOverride', + attributes: { 'w:ilvl': '2' }, + elements: [ + { name: 'w:startOverride', attributes: { 'w:val': '3' } }, + { name: 'w:lvl', elements: [{ name: 'w:lvlText', attributes: { 'w:val': '%1.' } }] }, + ], + }; + + const result = translator.encode({ nodes: [xmlNode] }); + + expect(result).toEqual({ + ilvl: 2, + startOverride: 3, + lvl: { lvlText: '%1.' }, + }); + }); + + it('should return attributes if no child properties are present', () => { + const xmlNode = { name: 'w:lvlOverride', attributes: { 'w:ilvl': '2' }, elements: [] }; + const result = translator.encode({ nodes: [xmlNode] }); + expect(result).toEqual({ ilvl: 2 }); + }); + }); + + describe('decode', () => { + it('should decode a lvlOverride object correctly', () => { + const superDocNode = { + attrs: { + lvlOverride: { + ilvl: 2, + startOverride: 3, + lvl: { lvlText: '%1.' }, + }, + }, + }; + + const result = translator.decode({ node: superDocNode }); + + expect(result.name).toBe('w:lvlOverride'); + expect(result.attributes).toEqual({ 'w:ilvl': '2' }); + expect(result.elements).toEqual( + expect.arrayContaining([ + { name: 'w:startOverride', attributes: { 'w:val': '3' } }, + { + name: 'w:lvl', + type: 'element', + attributes: {}, + elements: [{ name: 'w:lvlText', attributes: { 'w:val': '%1.' } }], + }, + ]), + ); + }); + + it('should return undefined if no lvlOverride properties are present', () => { + const result = translator.decode({ node: { attrs: {} } }); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlPicBulletId/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlPicBulletId/index.js new file mode 100644 index 0000000000..c394ab108f --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlPicBulletId/index.js @@ -0,0 +1 @@ +export * from './lvlPicBulletId-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlPicBulletId/lvlPicBulletId-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlPicBulletId/lvlPicBulletId-translator.js new file mode 100644 index 0000000000..ce9144ff15 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlPicBulletId/lvlPicBulletId-translator.js @@ -0,0 +1,9 @@ +import { NodeTranslator } from '@translator'; +import { createSingleIntegerPropertyHandler } from '@converter/v3/handlers/utils'; + +/** + * The NodeTranslator instance for the w:lvlPicBulletId element. + * @type {import('@translator').NodeTranslator} + * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 707 + */ +export const translator = NodeTranslator.from(createSingleIntegerPropertyHandler('w:lvlPicBulletId')); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlPicBulletId/lvlPicBulletId-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlPicBulletId/lvlPicBulletId-translator.test.js new file mode 100644 index 0000000000..0e1ac5ded9 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlPicBulletId/lvlPicBulletId-translator.test.js @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { translator } from './lvlPicBulletId-translator.js'; + +describe('w:lvlPicBulletId translator', () => { + describe('encode', () => { + it('extracts the w:val attribute', () => { + const result = translator.encode({ nodes: [{ attributes: { 'w:val': '1' } }] }); + expect(result).toBe(1); + }); + + it('returns undefined if w:val is missing', () => { + const result = translator.encode({ nodes: [{ attributes: {} }] }); + expect(result).toBeUndefined(); + }); + }); + + describe('decode', () => { + it('creates a w:lvlPicBulletId element with the value in w:val', () => { + const { attributes: result } = translator.decode({ node: { attrs: { lvlPicBulletId: 1 } } }); + expect(result).toEqual({ 'w:val': '1' }); + }); + + it('returns undefined if lvlPicBulletId property is missing', () => { + const result = translator.decode({ node: { attrs: {} } }); + expect(result).toBeUndefined(); + }); + }); + + it('has correct metadata', () => { + expect(translator.xmlName).toBe('w:lvlPicBulletId'); + expect(translator.sdNodeOrKeyName).toBe('lvlPicBulletId'); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlRestart/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlRestart/index.js new file mode 100644 index 0000000000..02546bf0a7 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlRestart/index.js @@ -0,0 +1 @@ +export * from './lvlRestart-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlRestart/lvlRestart-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlRestart/lvlRestart-translator.js new file mode 100644 index 0000000000..8753bbf991 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlRestart/lvlRestart-translator.js @@ -0,0 +1,9 @@ +import { NodeTranslator } from '@translator'; +import { createSingleIntegerPropertyHandler } from '@converter/v3/handlers/utils'; + +/** + * The NodeTranslator instance for the w:lvlRestart element. + * @type {import('@translator').NodeTranslator} + * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 708 + */ +export const translator = NodeTranslator.from(createSingleIntegerPropertyHandler('w:lvlRestart')); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlRestart/lvlRestart-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlRestart/lvlRestart-translator.test.js new file mode 100644 index 0000000000..2c423bb16e --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlRestart/lvlRestart-translator.test.js @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { translator } from './lvlRestart-translator.js'; + +describe('w:lvlRestart translator', () => { + describe('encode', () => { + it('extracts the w:val attribute', () => { + const result = translator.encode({ nodes: [{ attributes: { 'w:val': '1' } }] }); + expect(result).toBe(1); + }); + + it('returns undefined if w:val is missing', () => { + const result = translator.encode({ nodes: [{ attributes: {} }] }); + expect(result).toBeUndefined(); + }); + }); + + describe('decode', () => { + it('creates a w:lvlRestart element with the value in w:val', () => { + const { attributes: result } = translator.decode({ node: { attrs: { lvlRestart: 1 } } }); + expect(result).toEqual({ 'w:val': '1' }); + }); + + it('returns undefined if lvlRestart property is missing', () => { + const result = translator.decode({ node: { attrs: {} } }); + expect(result).toBeUndefined(); + }); + }); + + it('has correct metadata', () => { + expect(translator.xmlName).toBe('w:lvlRestart'); + expect(translator.sdNodeOrKeyName).toBe('lvlRestart'); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlText/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlText/index.js new file mode 100644 index 0000000000..b1899bd003 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlText/index.js @@ -0,0 +1 @@ +export * from './lvlText-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlText/lvlText-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlText/lvlText-translator.js new file mode 100644 index 0000000000..7c6465c0ea --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlText/lvlText-translator.js @@ -0,0 +1,9 @@ +import { NodeTranslator } from '@translator'; +import { createSingleAttrPropertyHandler } from '../../utils.js'; + +/** + * The NodeTranslator instance for the lvlText element. + * @type {import('@translator').NodeTranslator} + * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 711 + */ +export const translator = NodeTranslator.from(createSingleAttrPropertyHandler('w:lvlText')); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlText/lvlText-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlText/lvlText-translator.test.js new file mode 100644 index 0000000000..724180816b --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/lvlText/lvlText-translator.test.js @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { translator } from './lvlText-translator.js'; + +describe('w:lvlText translator', () => { + describe('encode', () => { + it('extracts the w:val attribute', () => { + const result = translator.encode({ nodes: [{ attributes: { 'w:val': '%1.' } }] }); + expect(result).toBe('%1.'); + }); + + it('returns undefined if w:val is missing', () => { + const result = translator.encode({ nodes: [{ attributes: {} }] }); + expect(result).toBeUndefined(); + }); + }); + + describe('decode', () => { + it('creates a w:lvlText element with the value in w:val', () => { + const { attributes: result } = translator.decode({ node: { attrs: { lvlText: '%1.' } } }); + expect(result).toEqual({ 'w:val': '%1.' }); + }); + + it('returns undefined if lvlText property is missing', () => { + const result = translator.decode({ node: { attrs: {} } }); + expect(result).toBeUndefined(); + }); + }); + + it('has correct metadata', () => { + expect(translator.xmlName).toBe('w:lvlText'); + expect(translator.sdNodeOrKeyName).toBe('lvlText'); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/multiLevelType/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/multiLevelType/index.js new file mode 100644 index 0000000000..226f2c60c7 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/multiLevelType/index.js @@ -0,0 +1 @@ +export * from './multiLevelType-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/multiLevelType/multiLevelType-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/multiLevelType/multiLevelType-translator.js new file mode 100644 index 0000000000..e3324d7a88 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/multiLevelType/multiLevelType-translator.js @@ -0,0 +1,9 @@ +import { NodeTranslator } from '@translator'; +import { createSingleAttrPropertyHandler } from '../../utils.js'; + +/** + * The NodeTranslator instance for the multiLevelType element. + * @type {import('@translator').NodeTranslator} + * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 712 + */ +export const translator = NodeTranslator.from(createSingleAttrPropertyHandler('w:multiLevelType')); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/multiLevelType/multiLevelType-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/multiLevelType/multiLevelType-translator.test.js new file mode 100644 index 0000000000..cbdbff93c8 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/multiLevelType/multiLevelType-translator.test.js @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { translator } from './multiLevelType-translator.js'; + +describe('w:multiLevelType translator', () => { + describe('encode', () => { + it('extracts the w:val attribute', () => { + const result = translator.encode({ nodes: [{ attributes: { 'w:val': 'multilevel' } }] }); + expect(result).toBe('multilevel'); + }); + + it('returns undefined if w:val is missing', () => { + const result = translator.encode({ nodes: [{ attributes: {} }] }); + expect(result).toBeUndefined(); + }); + }); + + describe('decode', () => { + it('creates a w:multiLevelType element with the value in w:val', () => { + const { attributes: result } = translator.decode({ node: { attrs: { multiLevelType: 'multilevel' } } }); + expect(result).toEqual({ 'w:val': 'multilevel' }); + }); + + it('returns undefined if multiLevelType property is missing', () => { + const result = translator.decode({ node: { attrs: {} } }); + expect(result).toBeUndefined(); + }); + }); + + it('has correct metadata', () => { + expect(translator.xmlName).toBe('w:multiLevelType'); + expect(translator.sdNodeOrKeyName).toBe('multiLevelType'); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/name/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/name/index.js new file mode 100644 index 0000000000..c02a15aabd --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/name/index.js @@ -0,0 +1 @@ +export * from './name-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/name/name-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/name/name-translator.js new file mode 100644 index 0000000000..de938baa2e --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/name/name-translator.js @@ -0,0 +1,9 @@ +import { NodeTranslator } from '@translator'; +import { createSingleAttrPropertyHandler } from '../../utils.js'; + +/** + * The NodeTranslator instance for the name element. + * @type {import('@translator').NodeTranslator} + * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 391 + */ +export const translator = NodeTranslator.from(createSingleAttrPropertyHandler('w:name')); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/name/name-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/name/name-translator.test.js new file mode 100644 index 0000000000..d1106a1adc --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/name/name-translator.test.js @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { translator } from './name-translator.js'; + +describe('w:name translator', () => { + describe('encode', () => { + it('extracts the w:val attribute', () => { + const result = translator.encode({ nodes: [{ attributes: { 'w:val': 'Name' } }] }); + expect(result).toBe('Name'); + }); + + it('returns undefined if w:val is missing', () => { + const result = translator.encode({ nodes: [{ attributes: {} }] }); + expect(result).toBeUndefined(); + }); + }); + + describe('decode', () => { + it('creates a w:name element with the value in w:val', () => { + const { attributes: result } = translator.decode({ node: { attrs: { name: 'Name' } } }); + expect(result).toEqual({ 'w:val': 'Name' }); + }); + + it('returns undefined if name property is missing', () => { + const result = translator.decode({ node: { attrs: {} } }); + expect(result).toBeUndefined(); + }); + }); + + it('has correct metadata', () => { + expect(translator.xmlName).toBe('w:name'); + expect(translator.sdNodeOrKeyName).toBe('name'); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/next/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/next/index.js new file mode 100644 index 0000000000..4fcb19fa11 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/next/index.js @@ -0,0 +1 @@ +export * from './next-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/next/next-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/next/next-translator.js new file mode 100644 index 0000000000..00deedd969 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/next/next-translator.js @@ -0,0 +1,9 @@ +import { NodeTranslator } from '@translator'; +import { createSingleAttrPropertyHandler } from '../../utils.js'; + +/** + * The NodeTranslator instance for the next element. + * @type {import('@translator').NodeTranslator} + * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 391 + */ +export const translator = NodeTranslator.from(createSingleAttrPropertyHandler('w:next')); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/next/next-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/next/next-translator.test.js new file mode 100644 index 0000000000..a951cf2511 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/next/next-translator.test.js @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { translator } from './next-translator.js'; + +describe('w:next translator', () => { + describe('encode', () => { + it('extracts the w:val attribute', () => { + const result = translator.encode({ nodes: [{ attributes: { 'w:val': 'NextStyle' } }] }); + expect(result).toBe('NextStyle'); + }); + + it('returns undefined if w:val is missing', () => { + const result = translator.encode({ nodes: [{ attributes: {} }] }); + expect(result).toBeUndefined(); + }); + }); + + describe('decode', () => { + it('creates a w:next element with the value in w:val', () => { + const { attributes: result } = translator.decode({ node: { attrs: { next: 'NextStyle' } } }); + expect(result).toEqual({ 'w:val': 'NextStyle' }); + }); + + it('returns undefined if next property is missing', () => { + const result = translator.decode({ node: { attrs: {} } }); + expect(result).toBeUndefined(); + }); + }); + + it('has correct metadata', () => { + expect(translator.xmlName).toBe('w:next'); + expect(translator.sdNodeOrKeyName).toBe('next'); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/nsid/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/nsid/index.js new file mode 100644 index 0000000000..7ed86b3955 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/nsid/index.js @@ -0,0 +1 @@ +export * from './nsid-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/nsid/nsid-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/nsid/nsid-translator.js new file mode 100644 index 0000000000..239595c485 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/nsid/nsid-translator.js @@ -0,0 +1,9 @@ +import { NodeTranslator } from '@translator'; +import { createSingleIntegerPropertyHandler } from '@converter/v3/handlers/utils'; + +/** + * The NodeTranslator instance for the w:nsid element. + * @type {import('@translator').NodeTranslator} + * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 714 + */ +export const translator = NodeTranslator.from(createSingleIntegerPropertyHandler('w:nsid')); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/nsid/nsid-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/nsid/nsid-translator.test.js new file mode 100644 index 0000000000..31a9f75730 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/nsid/nsid-translator.test.js @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { translator } from './nsid-translator.js'; + +describe('w:nsid translator', () => { + describe('encode', () => { + it('extracts the w:val attribute', () => { + const result = translator.encode({ nodes: [{ attributes: { 'w:val': '1' } }] }); + expect(result).toBe(1); + }); + + it('returns undefined if w:val is missing', () => { + const result = translator.encode({ nodes: [{ attributes: {} }] }); + expect(result).toBeUndefined(); + }); + }); + + describe('decode', () => { + it('creates a w:nsid element with the value in w:val', () => { + const { attributes: result } = translator.decode({ node: { attrs: { nsid: 1 } } }); + expect(result).toEqual({ 'w:val': '1' }); + }); + + it('returns undefined if nsid property is missing', () => { + const result = translator.decode({ node: { attrs: {} } }); + expect(result).toBeUndefined(); + }); + }); + + it('has correct metadata', () => { + expect(translator.xmlName).toBe('w:nsid'); + expect(translator.sdNodeOrKeyName).toBe('nsid'); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/num/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/num/index.js new file mode 100644 index 0000000000..7fd1b2ef96 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/num/index.js @@ -0,0 +1 @@ +export * from './num-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/num/num-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/num/num-translator.js new file mode 100644 index 0000000000..050f95c614 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/num/num-translator.js @@ -0,0 +1,62 @@ +import { NodeTranslator } from '@translator'; +import { translator as wAbstractNumIdTranslator } from '../../w/abstractNumId'; +import { translator as wLvlOverrideTranslator } from '../../w/lvlOverride'; +import { + createIntegerAttributeHandler, + encodeProperties, + decodeProperties, + encodePropertiesByKey, + decodePropertiesByKey, +} from '@converter/v3/handlers/utils.js'; + +/** + * The NodeTranslator instance for the w:num element. + * @type {import('@translator').NodeTranslator} + */ +export const translator = NodeTranslator.from({ + xmlName: 'w:num', + sdNodeOrKeyName: 'num', + type: NodeTranslator.translatorTypes.NODE, + attributes: [createIntegerAttributeHandler('w:numId')], + encode: (params, encodedAttrs) => { + const { nodes } = params; + const node = nodes[0]; + const result = { + ...encodedAttrs, + ...encodeProperties(params, { + 'w:abstractNumId': wAbstractNumIdTranslator, + }), + ...encodePropertiesByKey('w:lvlOverride', 'lvlOverrides', wLvlOverrideTranslator, params, node, 'ilvl'), + }; + + return result; + }, + decode: function (params) { + const currentValue = params.node.attrs?.['num']; + if (!currentValue) { + return undefined; + } + + const decodedAttrs = this.decodeAttributes({ node: { ...params.node, attrs: currentValue } }); + + const elements = [ + ...decodeProperties( + params, + { + abstractNumId: wAbstractNumIdTranslator, + }, + currentValue, + ), + ...decodePropertiesByKey('w:lvlOverride', 'lvlOverrides', wLvlOverrideTranslator, params, currentValue), + ]; + + const newNode = { + name: 'w:num', + type: 'element', + attributes: decodedAttrs, + elements: elements, + }; + + return newNode; + }, +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/num/num-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/num/num-translator.test.js new file mode 100644 index 0000000000..a0d8ed0564 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/num/num-translator.test.js @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest'; +import { translator } from './num-translator.js'; +import { NodeTranslator } from '@translator'; + +describe('w:num translator', () => { + describe('config', () => { + it('should have correct properties', () => { + expect(translator.xmlName).toBe('w:num'); + expect(translator.sdNodeOrKeyName).toBe('num'); + expect(translator).toBeInstanceOf(NodeTranslator); + }); + }); + + describe('encode', () => { + it('should encode nested num properties correctly', () => { + const xmlNode = { + name: 'w:num', + attributes: { 'w:numId': '5' }, + elements: [ + { name: 'w:abstractNumId', attributes: { 'w:val': '2' } }, + { + name: 'w:lvlOverride', + attributes: { 'w:ilvl': '1' }, + elements: [ + { name: 'w:startOverride', attributes: { 'w:val': '3' } }, + { name: 'w:lvl', elements: [{ name: 'w:lvlText', attributes: { 'w:val': '%1.' } }] }, + ], + }, + ], + }; + + const result = translator.encode({ nodes: [xmlNode] }); + + expect(result).toEqual({ + numId: 5, + abstractNumId: 2, + lvlOverrides: { + 1: { + ilvl: 1, + startOverride: 3, + lvl: { lvlText: '%1.' }, + }, + }, + }); + }); + + it('should return attributes when no child properties are present', () => { + const xmlNode = { name: 'w:num', attributes: { 'w:numId': '5' }, elements: [] }; + const result = translator.encode({ nodes: [xmlNode] }); + expect(result).toEqual({ numId: 5 }); + }); + }); + + describe('decode', () => { + it('should decode a styles object correctly', () => { + const superDocNode = { + attrs: { + num: { + numId: 5, + abstractNumId: 2, + lvlOverrides: { + 1: { + ilvl: 1, + startOverride: 3, + lvl: { lvlText: '%1.' }, + }, + }, + }, + }, + }; + + const result = translator.decode({ node: superDocNode }); + + expect(result.name).toBe('w:num'); + expect(result.attributes).toEqual({ 'w:numId': '5' }); + expect(result.elements).toEqual([ + { name: 'w:abstractNumId', attributes: { 'w:val': '2' } }, + { + name: 'w:lvlOverride', + type: 'element', + attributes: { 'w:ilvl': '1' }, + elements: [ + { name: 'w:startOverride', attributes: { 'w:val': '3' } }, + { + name: 'w:lvl', + type: 'element', + attributes: {}, + elements: [{ name: 'w:lvlText', attributes: { 'w:val': '%1.' } }], + }, + ], + }, + ]); + }); + + it('should return undefined if no styles are present', () => { + const result = translator.decode({ node: { attrs: {} } }); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/numFmt/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/numFmt/index.js new file mode 100644 index 0000000000..0e3e42fb89 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/numFmt/index.js @@ -0,0 +1 @@ +export * from './numFmt-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/numFmt/numFmt-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/numFmt/numFmt-translator.js new file mode 100644 index 0000000000..412ae1251c --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/numFmt/numFmt-translator.js @@ -0,0 +1,20 @@ +import { NodeTranslator } from '@translator'; +import { createAttributeHandler } from '@converter/v3/handlers/utils'; + +/** + * The NodeTranslator instance for the w:numFmt element. + * @type {import('@translator').NodeTranslator} + * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 208 + */ +export const translator = NodeTranslator.from({ + xmlName: 'w:numFmt', + sdNodeOrKeyName: 'numFmt', + attributes: [createAttributeHandler('w:val'), createAttributeHandler('w:format')], + encode: (_, encodedAttrs) => { + return encodedAttrs; + }, + decode: function ({ node }) { + const decodedAttrs = this.decodeAttributes({ node: { ...node, attrs: node.attrs['numFmt'] || {} } }); + return Object.keys(decodedAttrs).length > 0 ? { attributes: decodedAttrs } : undefined; + }, +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/numFmt/numFmt-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/numFmt/numFmt-translator.test.js new file mode 100644 index 0000000000..583581badd --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/numFmt/numFmt-translator.test.js @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import { translator } from './numFmt-translator.js'; + +describe('numFmt-translator', () => { + describe('decode', () => { + it('should return the decoded attributes from a node', () => { + const node = { + attrs: { + numFmt: { + val: 'decimal', + format: '1.', + }, + }, + }; + expect(translator.decode({ node })).toEqual({ + attributes: { + 'w:val': 'decimal', + 'w:format': '1.', + }, + }); + }); + + it('should return only the existing attributes from a node', () => { + const node = { + attrs: { + numFmt: { + val: 'decimal', + }, + }, + }; + expect(translator.decode({ node })).toEqual({ + attributes: { + 'w:val': 'decimal', + }, + }); + }); + + it('should return undefined if "numFmt" attribute is not present in the node', () => { + const node = { + attrs: {}, + }; + expect(translator.decode({ node })).toBeUndefined(); + }); + + it('should return undefined if "numFmt" attribute is an empty object', () => { + const node = { + attrs: { + numFmt: {}, + }, + }; + expect(translator.decode({ node })).toBeUndefined(); + }); + }); + + describe('encode', () => { + it('should return the encoded attributes for a w:numFmt node', () => { + const sdNode = { + attributes: { + 'w:val': 'decimal', + 'w:format': '1.', + }, + }; + expect(translator.encode({ nodes: [sdNode] })).toEqual({ + val: 'decimal', + format: '1.', + }); + }); + + it('should return only the existing attributes for a w:numFmt node', () => { + const sdNode = { + attributes: { + 'w:val': 'decimal', + }, + }; + expect(translator.encode({ nodes: [sdNode] })).toEqual({ + val: 'decimal', + }); + }); + + it('should return an empty object for a w:numFmt node if no attributes are passed', () => { + const sdNode = { + attributes: {}, + }; + expect(translator.encode({ nodes: [sdNode] })).toEqual({}); + }); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/numIdMacAtCleanup/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/numIdMacAtCleanup/index.js new file mode 100644 index 0000000000..fd7653fdb6 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/numIdMacAtCleanup/index.js @@ -0,0 +1 @@ +export * from './numIdMacAtCleanup-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/numIdMacAtCleanup/numIdMacAtCleanup-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/numIdMacAtCleanup/numIdMacAtCleanup-translator.js new file mode 100644 index 0000000000..205e0790e3 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/numIdMacAtCleanup/numIdMacAtCleanup-translator.js @@ -0,0 +1,9 @@ +import { NodeTranslator } from '@translator'; +import { createSingleIntegerPropertyHandler } from '@converter/v3/handlers/utils'; + +/** + * The NodeTranslator instance for the w:numIdMacAtCleanup element. + * @type {import('@translator').NodeTranslator} + * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 720 + */ +export const translator = NodeTranslator.from(createSingleIntegerPropertyHandler('w:numIdMacAtCleanup')); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/numIdMacAtCleanup/numIdMacAtCleanup-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/numIdMacAtCleanup/numIdMacAtCleanup-translator.test.js new file mode 100644 index 0000000000..9179f9c7d6 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/numIdMacAtCleanup/numIdMacAtCleanup-translator.test.js @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { translator } from './numIdMacAtCleanup-translator.js'; + +describe('w:numIdMacAtCleanup translator', () => { + describe('encode', () => { + it('extracts the w:val attribute', () => { + const result = translator.encode({ nodes: [{ attributes: { 'w:val': '1' } }] }); + expect(result).toBe(1); + }); + + it('returns undefined if w:val is missing', () => { + const result = translator.encode({ nodes: [{ attributes: {} }] }); + expect(result).toBeUndefined(); + }); + }); + + describe('decode', () => { + it('creates a w:numIdMacAtCleanup element with the value in w:val', () => { + const { attributes: result } = translator.decode({ node: { attrs: { numIdMacAtCleanup: 1 } } }); + expect(result).toEqual({ 'w:val': '1' }); + }); + + it('returns undefined if numIdMacAtCleanup property is missing', () => { + const result = translator.decode({ node: { attrs: {} } }); + expect(result).toBeUndefined(); + }); + }); + + it('has correct metadata', () => { + expect(translator.xmlName).toBe('w:numIdMacAtCleanup'); + expect(translator.sdNodeOrKeyName).toBe('numIdMacAtCleanup'); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/numPr/numPr-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/numPr/numPr-translator.js index 1b0904d477..b155f09050 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/numPr/numPr-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/numPr/numPr-translator.js @@ -8,7 +8,7 @@ import { translator as wNumIdTranslator } from '../numId'; // Property translators for w:numPr child elements // Each translator handles a specific property of the numbering properties -/** @type {import('@translator').NodeTranslatorConfig[]} */ +/** @type {import('@translator').NodeTranslator[]} */ const propertyTranslators = [mcAlternateContentTranslator, wIlvlTranslator, wInsTranslator, wNumIdTranslator]; /** diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/numStyleLink/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/numStyleLink/index.js new file mode 100644 index 0000000000..1843fcd093 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/numStyleLink/index.js @@ -0,0 +1 @@ +export * from './numStyleLink-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/numStyleLink/numStyleLink-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/numStyleLink/numStyleLink-translator.js new file mode 100644 index 0000000000..adfa86d528 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/numStyleLink/numStyleLink-translator.js @@ -0,0 +1,9 @@ +import { NodeTranslator } from '@translator'; +import { createSingleAttrPropertyHandler } from '../../utils.js'; + +/** + * The NodeTranslator instance for the numStyleLink element. + * @type {import('@translator').NodeTranslator} + * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 723 + */ +export const translator = NodeTranslator.from(createSingleAttrPropertyHandler('w:numStyleLink')); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/numStyleLink/numStyleLink-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/numStyleLink/numStyleLink-translator.test.js new file mode 100644 index 0000000000..375a6c6eb1 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/numStyleLink/numStyleLink-translator.test.js @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { translator } from './numStyleLink-translator.js'; + +describe('w:numStyleLink translator', () => { + describe('encode', () => { + it('extracts the w:val attribute', () => { + const result = translator.encode({ nodes: [{ attributes: { 'w:val': 'ListStyle' } }] }); + expect(result).toBe('ListStyle'); + }); + + it('returns undefined if w:val is missing', () => { + const result = translator.encode({ nodes: [{ attributes: {} }] }); + expect(result).toBeUndefined(); + }); + }); + + describe('decode', () => { + it('creates a w:numStyleLink element with the value in w:val', () => { + const { attributes: result } = translator.decode({ node: { attrs: { numStyleLink: 'ListStyle' } } }); + expect(result).toEqual({ 'w:val': 'ListStyle' }); + }); + + it('returns undefined if numStyleLink property is missing', () => { + const result = translator.decode({ node: { attrs: {} } }); + expect(result).toBeUndefined(); + }); + }); + + it('has correct metadata', () => { + expect(translator.xmlName).toBe('w:numStyleLink'); + expect(translator.sdNodeOrKeyName).toBe('numStyleLink'); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/numbering/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/numbering/index.js new file mode 100644 index 0000000000..155ab2f2e8 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/numbering/index.js @@ -0,0 +1 @@ +export * from './numbering-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/numbering/numbering-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/numbering/numbering-translator.js new file mode 100644 index 0000000000..8659dc628b --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/numbering/numbering-translator.js @@ -0,0 +1,83 @@ +import { NodeTranslator } from '@translator'; +import { translator as wNsidTranslator } from '../../w/nsid'; +import { translator as wTmplTranslator } from '../../w/tmpl'; +import { translator as wNameTranslator } from '../../w/name'; +import { translator as wStyleLinkTranslator } from '../../w/styleLink'; +import { translator as wNumStyleLinkTranslator } from '../../w/numStyleLink'; +import { translator as wMultiLevelTypeTranslator } from '../../w/multiLevelType'; +import { translator as wAbstractNumTranslator } from '../../w/abstractNum'; +import { translator as wNumTranslator } from '../../w/num'; +import { translator as wNumIdMacAtCleanupTranslator } from '../../w/numIdMacAtCleanup'; +import { + encodeProperties, + decodeProperties, + encodePropertiesByKey, + decodePropertiesByKey, +} from '@converter/v3/handlers/utils.js'; + +const propertyTranslators = [ + wNsidTranslator, + wTmplTranslator, + wNameTranslator, + wStyleLinkTranslator, + wNumStyleLinkTranslator, + wMultiLevelTypeTranslator, + wNumIdMacAtCleanupTranslator, +]; + +const propertyTranslatorsByXmlName = {}; +const propertyTranslatorsBySdName = {}; +propertyTranslators.forEach((translator) => { + propertyTranslatorsByXmlName[translator.xmlName] = translator; + propertyTranslatorsBySdName[translator.sdNodeOrKeyName] = translator; +}); + +/** + * The NodeTranslator instance for the w:num element. + * @type {import('@translator').NodeTranslator} + */ +export const translator = NodeTranslator.from({ + xmlName: 'w:numbering', + sdNodeOrKeyName: 'numbering', + type: NodeTranslator.translatorTypes.NODE, + attributes: [], + encode: (params, encodedAttrs) => { + const { nodes } = params; + const node = nodes[0]; + + const props = encodeProperties(params, propertyTranslatorsByXmlName); + + const result = { + ...encodedAttrs, + ...props, + ...encodePropertiesByKey('w:abstractNum', 'abstracts', wAbstractNumTranslator, params, node, 'abstractNumId'), + ...encodePropertiesByKey('w:num', 'definitions', wNumTranslator, params, node, 'numId'), + }; + + return result; + }, + decode: function (params) { + const currentValue = params.node.attrs?.['abstractNum']; + if (!currentValue) { + return undefined; + } + + const decodedAttrs = this.decodeAttributes({ node: { ...params.node, attrs: currentValue } }); + + const props = decodeProperties(params, propertyTranslatorsBySdName, currentValue); + const elements = [ + ...props, + ...decodePropertiesByKey('w:abstractNum', 'abstracts', wAbstractNumTranslator, params, currentValue), + ...decodePropertiesByKey('w:num', 'definitions', wNumTranslator, params, currentValue), + ]; + + const newNode = { + name: 'w:numbering', + type: 'element', + attributes: decodedAttrs, + elements: elements, + }; + + return newNode; + }, +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/numbering/numbering-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/numbering/numbering-translator.test.js new file mode 100644 index 0000000000..cf7b69d49d --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/numbering/numbering-translator.test.js @@ -0,0 +1,128 @@ +import { describe, it, expect } from 'vitest'; +import { translator } from './numbering-translator.js'; +import { NodeTranslator } from '@translator'; + +describe('w:numbering translator', () => { + describe('config', () => { + it('should have correct properties', () => { + expect(translator.xmlName).toBe('w:numbering'); + expect(translator.sdNodeOrKeyName).toBe('numbering'); + expect(translator).toBeInstanceOf(NodeTranslator); + }); + }); + + describe('encode', () => { + it('should encode numbering properties correctly', () => { + const xmlNode = { + name: 'w:numbering', + elements: [ + { name: 'w:nsid', attributes: { 'w:val': '10' } }, + { name: 'w:tmpl', attributes: { 'w:val': '20' } }, + { name: 'w:name', attributes: { 'w:val': 'List Numbering' } }, + { name: 'w:styleLink', attributes: { 'w:val': 'ListStyle' } }, + { name: 'w:numStyleLink', attributes: { 'w:val': 'ListNumStyle' } }, + { name: 'w:multiLevelType', attributes: { 'w:val': 'multilevel' } }, + { + name: 'w:abstractNum', + attributes: { 'w:abstractNumId': '1' }, + elements: [{ name: 'w:name', attributes: { 'w:val': 'Abstract List' } }], + }, + { + name: 'w:num', + attributes: { 'w:numId': '5' }, + elements: [{ name: 'w:abstractNumId', attributes: { 'w:val': '1' } }], + }, + ], + }; + + const result = translator.encode({ nodes: [xmlNode] }); + + expect(result).toEqual({ + nsid: 10, + tmpl: 20, + name: 'List Numbering', + styleLink: 'ListStyle', + numStyleLink: 'ListNumStyle', + multiLevelType: 'multilevel', + abstracts: { + 1: { + abstractNumId: 1, + name: 'Abstract List', + }, + }, + definitions: { + 5: { + numId: 5, + abstractNumId: 1, + }, + }, + }); + }); + + it('should return an empty object when no elements are present', () => { + const xmlNode = { name: 'w:numbering', elements: [] }; + const result = translator.encode({ nodes: [xmlNode] }); + expect(result).toEqual({}); + }); + }); + + describe('decode', () => { + it('should decode numbering into w:numbering elements', () => { + const superDocNode = { + attrs: { + abstractNum: { + nsid: 10, + tmpl: 20, + name: 'List Numbering', + styleLink: 'ListStyle', + numStyleLink: 'ListNumStyle', + multiLevelType: 'multilevel', + abstracts: { + 1: { + abstractNumId: 1, + name: 'Abstract List', + }, + }, + definitions: { + 5: { + numId: 5, + abstractNumId: 1, + }, + }, + }, + }, + }; + + const result = translator.decode({ node: superDocNode }); + + expect(result.name).toBe('w:numbering'); + expect(result.elements).toEqual( + expect.arrayContaining([ + { name: 'w:nsid', attributes: { 'w:val': '10' } }, + { name: 'w:tmpl', attributes: { 'w:val': '20' } }, + { name: 'w:name', attributes: { 'w:val': 'List Numbering' } }, + { name: 'w:styleLink', attributes: { 'w:val': 'ListStyle' } }, + { name: 'w:numStyleLink', attributes: { 'w:val': 'ListNumStyle' } }, + { name: 'w:multiLevelType', attributes: { 'w:val': 'multilevel' } }, + { + name: 'w:abstractNum', + type: 'element', + attributes: { 'w:abstractNumId': '1' }, + elements: [{ name: 'w:name', attributes: { 'w:val': 'Abstract List' } }], + }, + { + name: 'w:num', + type: 'element', + attributes: { 'w:numId': '5' }, + elements: [{ name: 'w:abstractNumId', attributes: { 'w:val': '1' } }], + }, + ]), + ); + }); + + it('should return undefined if no abstractNum is present', () => { + const result = translator.decode({ node: { attrs: {} } }); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js index 24a5d79f68..904ed2c5a2 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js @@ -38,15 +38,8 @@ export const handleParagraphNode = (params) => { } // Resolve paragraph properties according to styles hierarchy - const insideTable = (params.path || []).some((ancestor) => ancestor.name === 'w:tc'); const tableStyleId = getTableStyleId(params.path || []); - const resolvedParagraphProperties = resolveParagraphProperties( - params, - inlineParagraphProperties, - insideTable, - false, - tableStyleId, - ); + const resolvedParagraphProperties = resolveParagraphProperties(params, inlineParagraphProperties, { tableStyleId }); const { elements = [], attributes = {}, marks = [] } = parseProperties(node, params.docx); const childContent = []; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/pBdr/pBdr-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/pBdr/pBdr-translator.js index 0e1a3a7018..019f2fd3bf 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/pBdr/pBdr-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/pBdr/pBdr-translator.js @@ -11,7 +11,7 @@ import { translator as wTop } from '../top'; // Property translators for w:pBdr child elements // Each translator handles a specific property of the paragraph borders -/** @type {import('@translator').NodeTranslatorConfig[]} */ +/** @type {import('@translator').NodeTranslator[]} */ const propertyTranslators = [ mcAlternateContentTranslator, wBarTranslator, diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/pPr/pPr-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/pPr/pPr-translator.js index 20a6153cfc..212fe10601 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/pPr/pPr-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/pPr/pPr-translator.js @@ -39,7 +39,7 @@ import { translator as wRPrTranslator } from '../rpr'; // Property translators for w:pPr child elements // Each translator handles a specific property of the paragraph properties -/** @type {import('@translator').NodeTranslatorConfig[]} */ +/** @type {import('@translator').NodeTranslator[]} */ const propertyTranslators = [ mcAlternateContentTranslator, wAdjustRightIndTranslator, diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/personal/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/personal/index.js new file mode 100644 index 0000000000..863c4fec77 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/personal/index.js @@ -0,0 +1 @@ +export * from './personal-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/personal/personal-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/personal/personal-translator.js new file mode 100644 index 0000000000..62245e60e8 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/personal/personal-translator.js @@ -0,0 +1,9 @@ +import { NodeTranslator } from '@translator'; +import { createSingleBooleanPropertyHandler } from '@converter/v3/handlers/utils'; + +/** + * The NodeTranslator instance for the personal element. + * @type {import('@translator').NodeTranslator} + * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 636 + */ +export const translator = NodeTranslator.from(createSingleBooleanPropertyHandler('w:personal')); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/personal/personal-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/personal/personal-translator.test.js new file mode 100644 index 0000000000..dc325e2741 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/personal/personal-translator.test.js @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; + +import { translator } from './personal-translator.js'; +import { NodeTranslator } from '../../../node-translator/node-translator.js'; + +describe('w:personal translator (attribute)', () => { + it('exposes correct translator meta', () => { + expect(translator.xmlName).toBe('w:personal'); + expect(translator.sdNodeOrKeyName).toBe('personal'); + expect(typeof translator.encode).toBe('function'); + }); + + it('builds NodeTranslator instance', () => { + expect(translator).toBeInstanceOf(NodeTranslator); + expect(translator.xmlName).toBe('w:personal'); + expect(translator.sdNodeOrKeyName).toBe('personal'); + }); + + describe('encode', () => { + it('encodes with provided w:val as-is', () => { + const params = { nodes: [{ attributes: { 'w:val': '1' } }] }; + const out = translator.encode(params); + expect(out).toBe(true); + }); + + it('passes through raw attributes when missing encoded boolean', () => { + const params = { nodes: [{ attributes: {} }] }; + const out = translator.encode(params); + expect(out).toBe(true); + }); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/personalCompose/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/personalCompose/index.js new file mode 100644 index 0000000000..9a6a2c87e1 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/personalCompose/index.js @@ -0,0 +1 @@ +export * from './personalCompose-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/personalCompose/personalCompose-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/personalCompose/personalCompose-translator.js new file mode 100644 index 0000000000..ba905f34eb --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/personalCompose/personalCompose-translator.js @@ -0,0 +1,9 @@ +import { NodeTranslator } from '@translator'; +import { createSingleBooleanPropertyHandler } from '@converter/v3/handlers/utils'; + +/** + * The NodeTranslator instance for the personalCompose element. + * @type {import('@translator').NodeTranslator} + * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 636 + */ +export const translator = NodeTranslator.from(createSingleBooleanPropertyHandler('w:personalCompose')); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/personalCompose/personalCompose-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/personalCompose/personalCompose-translator.test.js new file mode 100644 index 0000000000..2d53ea9e70 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/personalCompose/personalCompose-translator.test.js @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; + +import { translator } from './personalCompose-translator.js'; +import { NodeTranslator } from '../../../node-translator/node-translator.js'; + +describe('w:personalCompose translator (attribute)', () => { + it('exposes correct translator meta', () => { + expect(translator.xmlName).toBe('w:personalCompose'); + expect(translator.sdNodeOrKeyName).toBe('personalCompose'); + expect(typeof translator.encode).toBe('function'); + }); + + it('builds NodeTranslator instance', () => { + expect(translator).toBeInstanceOf(NodeTranslator); + expect(translator.xmlName).toBe('w:personalCompose'); + expect(translator.sdNodeOrKeyName).toBe('personalCompose'); + }); + + describe('encode', () => { + it('encodes with provided w:val as-is', () => { + const params = { nodes: [{ attributes: { 'w:val': '1' } }] }; + const out = translator.encode(params); + expect(out).toBe(true); + }); + + it('passes through raw attributes when missing encoded boolean', () => { + const params = { nodes: [{ attributes: {} }] }; + const out = translator.encode(params); + expect(out).toBe(true); + }); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/personalReply/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/personalReply/index.js new file mode 100644 index 0000000000..481fa4c985 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/personalReply/index.js @@ -0,0 +1 @@ +export * from './personalReply-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/personalReply/personalReply-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/personalReply/personalReply-translator.js new file mode 100644 index 0000000000..52fe2c0bc0 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/personalReply/personalReply-translator.js @@ -0,0 +1,9 @@ +import { NodeTranslator } from '@translator'; +import { createSingleBooleanPropertyHandler } from '@converter/v3/handlers/utils'; + +/** + * The NodeTranslator instance for the personalReply element. + * @type {import('@translator').NodeTranslator} + * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 637 + */ +export const translator = NodeTranslator.from(createSingleBooleanPropertyHandler('w:personalReply')); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/personalReply/personalReply-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/personalReply/personalReply-translator.test.js new file mode 100644 index 0000000000..ff5d9e882e --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/personalReply/personalReply-translator.test.js @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; + +import { translator } from './personalReply-translator.js'; +import { NodeTranslator } from '../../../node-translator/node-translator.js'; + +describe('w:personalReply translator (attribute)', () => { + it('exposes correct translator meta', () => { + expect(translator.xmlName).toBe('w:personalReply'); + expect(translator.sdNodeOrKeyName).toBe('personalReply'); + expect(typeof translator.encode).toBe('function'); + }); + + it('builds NodeTranslator instance', () => { + expect(translator).toBeInstanceOf(NodeTranslator); + expect(translator.xmlName).toBe('w:personalReply'); + expect(translator.sdNodeOrKeyName).toBe('personalReply'); + }); + + describe('encode', () => { + it('encodes with provided w:val as-is', () => { + const params = { nodes: [{ attributes: { 'w:val': '1' } }] }; + const out = translator.encode(params); + expect(out).toBe(true); + }); + + it('passes through raw attributes when missing encoded boolean', () => { + const params = { nodes: [{ attributes: {} }] }; + const out = translator.encode(params); + expect(out).toBe(true); + }); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/qFormat/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/qFormat/index.js new file mode 100644 index 0000000000..798809fca0 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/qFormat/index.js @@ -0,0 +1 @@ +export * from './qFormat-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/qFormat/qFormat-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/qFormat/qFormat-translator.js new file mode 100644 index 0000000000..ec5e5ff077 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/qFormat/qFormat-translator.js @@ -0,0 +1,9 @@ +import { NodeTranslator } from '@translator'; +import { createSingleBooleanPropertyHandler } from '@converter/v3/handlers/utils'; + +/** + * The NodeTranslator instance for the qFormat element. + * @type {import('@translator').NodeTranslator} + * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 637 + */ +export const translator = NodeTranslator.from(createSingleBooleanPropertyHandler('w:qFormat')); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/qFormat/qFormat-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/qFormat/qFormat-translator.test.js new file mode 100644 index 0000000000..4cdcd52ef7 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/qFormat/qFormat-translator.test.js @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; + +import { translator } from './qFormat-translator.js'; +import { NodeTranslator } from '../../../node-translator/node-translator.js'; + +describe('w:qFormat translator (attribute)', () => { + it('exposes correct translator meta', () => { + expect(translator.xmlName).toBe('w:qFormat'); + expect(translator.sdNodeOrKeyName).toBe('qFormat'); + expect(typeof translator.encode).toBe('function'); + }); + + it('builds NodeTranslator instance', () => { + expect(translator).toBeInstanceOf(NodeTranslator); + expect(translator.xmlName).toBe('w:qFormat'); + expect(translator.sdNodeOrKeyName).toBe('qFormat'); + }); + + describe('encode', () => { + it('encodes with provided w:val as-is', () => { + const params = { nodes: [{ attributes: { 'w:val': '1' } }] }; + const out = translator.encode(params); + expect(out).toBe(true); + }); + + it('passes through raw attributes when missing encoded boolean', () => { + const params = { nodes: [{ attributes: {} }] }; + const out = translator.encode(params); + expect(out).toBe(true); + }); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/rpr/rpr-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/rpr/rpr-translator.js index c287dd64af..738ff34642 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/rpr/rpr-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/rpr/rpr-translator.js @@ -42,7 +42,7 @@ import { translator as webHiddenTranslator } from '../webHidden/webHidden-transl // Property translators for w:rPr child elements // Each translator handles a specific property of the run properties -/** @type {import('@translator').NodeTranslatorConfig[]} */ +/** @type {import('@translator').NodeTranslator[]} */ export const propertyTranslators = [ boldCsTranslator, boldTranslator, diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/rsid/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/rsid/index.js new file mode 100644 index 0000000000..c9b3e6ecec --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/rsid/index.js @@ -0,0 +1 @@ +export * from './rsid-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/rsid/rsid-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/rsid/rsid-translator.js new file mode 100644 index 0000000000..06a2ed32b3 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/rsid/rsid-translator.js @@ -0,0 +1,9 @@ +import { NodeTranslator } from '@translator'; +import { createSingleIntegerPropertyHandler } from '@converter/v3/handlers/utils'; + +/** + * The NodeTranslator instance for the w:rsid element. + * @type {import('@translator').NodeTranslator} + * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 638 + */ +export const translator = NodeTranslator.from(createSingleIntegerPropertyHandler('w:rsid')); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/rsid/rsid-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/rsid/rsid-translator.test.js new file mode 100644 index 0000000000..eac10b83d9 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/rsid/rsid-translator.test.js @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { translator } from './rsid-translator.js'; + +describe('w:rsid translator', () => { + describe('encode', () => { + it('extracts the w:val attribute', () => { + const result = translator.encode({ nodes: [{ attributes: { 'w:val': '42' } }] }); + expect(result).toBe(42); + }); + + it('returns undefined if w:val is missing', () => { + const result = translator.encode({ nodes: [{ attributes: {} }] }); + expect(result).toBeUndefined(); + }); + }); + + describe('decode', () => { + it('creates a w:rsid element with the value in w:val', () => { + const { attributes: result } = translator.decode({ node: { attrs: { rsid: 42 } } }); + expect(result).toEqual({ 'w:val': '42' }); + }); + + it('returns undefined if rsid property is missing', () => { + const result = translator.decode({ node: { attrs: {} } }); + expect(result).toBeUndefined(); + }); + }); + + it('has correct metadata', () => { + expect(translator.xmlName).toBe('w:rsid'); + expect(translator.sdNodeOrKeyName).toBe('rsid'); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/semiHidden/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/semiHidden/index.js new file mode 100644 index 0000000000..b03a7f7a35 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/semiHidden/index.js @@ -0,0 +1 @@ +export * from './semiHidden-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/semiHidden/semiHidden-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/semiHidden/semiHidden-translator.js new file mode 100644 index 0000000000..e36606809b --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/semiHidden/semiHidden-translator.js @@ -0,0 +1,9 @@ +import { NodeTranslator } from '@translator'; +import { createSingleBooleanPropertyHandler } from '@converter/v3/handlers/utils'; + +/** + * The NodeTranslator instance for the semiHidden element. + * @type {import('@translator').NodeTranslator} + * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 639 + */ +export const translator = NodeTranslator.from(createSingleBooleanPropertyHandler('w:semiHidden')); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/semiHidden/semiHidden-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/semiHidden/semiHidden-translator.test.js new file mode 100644 index 0000000000..6963745c93 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/semiHidden/semiHidden-translator.test.js @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; + +import { translator } from './semiHidden-translator.js'; +import { NodeTranslator } from '../../../node-translator/node-translator.js'; + +describe('w:semiHidden translator (attribute)', () => { + it('exposes correct translator meta', () => { + expect(translator.xmlName).toBe('w:semiHidden'); + expect(translator.sdNodeOrKeyName).toBe('semiHidden'); + expect(typeof translator.encode).toBe('function'); + }); + + it('builds NodeTranslator instance', () => { + expect(translator).toBeInstanceOf(NodeTranslator); + expect(translator.xmlName).toBe('w:semiHidden'); + expect(translator.sdNodeOrKeyName).toBe('semiHidden'); + }); + + describe('encode', () => { + it('encodes with provided w:val as-is', () => { + const params = { nodes: [{ attributes: { 'w:val': '1' } }] }; + const out = translator.encode(params); + expect(out).toBe(true); + }); + + it('passes through raw attributes when missing encoded boolean', () => { + const params = { nodes: [{ attributes: {} }] }; + const out = translator.encode(params); + expect(out).toBe(true); + }); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/start/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/start/index.js index 9105071d06..e36875c48a 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/start/index.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/start/index.js @@ -1,2 +1,3 @@ export * from './start-translator.js'; export { translator as marginStartTranslator } from './marginStart-translator.js'; +export { translator as lvlStartTranslator } from './lvlStart-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/start/lvlStart-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/start/lvlStart-translator.js new file mode 100644 index 0000000000..c353a68abd --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/start/lvlStart-translator.js @@ -0,0 +1,9 @@ +import { NodeTranslator } from '@translator'; +import { createSingleIntegerPropertyHandler } from '../../utils.js'; + +/** + * The NodeTranslator instance for the start element. + * @type {import('@translator').NodeTranslator} + * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 727 + */ +export const translator = NodeTranslator.from(createSingleIntegerPropertyHandler('w:start')); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/start/lvlStart-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/start/lvlStart-translator.test.js new file mode 100644 index 0000000000..3caadbc450 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/start/lvlStart-translator.test.js @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { translator } from './lvlStart-translator.js'; + +describe('w:start translator (lvlStart)', () => { + describe('encode', () => { + it('extracts the w:val attribute', () => { + const result = translator.encode({ nodes: [{ attributes: { 'w:val': '3' } }] }); + expect(result).toBe(3); + }); + + it('returns undefined if w:val is missing', () => { + const result = translator.encode({ nodes: [{ attributes: {} }] }); + expect(result).toBeUndefined(); + }); + }); + + describe('decode', () => { + it('creates a w:start element with the value in w:val', () => { + const { attributes: result } = translator.decode({ node: { attrs: { start: 3 } } }); + expect(result).toEqual({ 'w:val': '3' }); + }); + + it('returns undefined if start property is missing', () => { + const result = translator.decode({ node: { attrs: {} } }); + expect(result).toBeUndefined(); + }); + }); + + it('has correct metadata', () => { + expect(translator.xmlName).toBe('w:start'); + expect(translator.sdNodeOrKeyName).toBe('start'); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/startOverride/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/startOverride/index.js new file mode 100644 index 0000000000..ac61143c13 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/startOverride/index.js @@ -0,0 +1 @@ +export * from './startOverride-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/startOverride/startOverride-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/startOverride/startOverride-translator.js new file mode 100644 index 0000000000..b389e467e8 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/startOverride/startOverride-translator.js @@ -0,0 +1,9 @@ +import { NodeTranslator } from '@translator'; +import { createSingleIntegerPropertyHandler } from '@converter/v3/handlers/utils'; + +/** + * The NodeTranslator instance for the w:startOverride element. + * @type {import('@translator').NodeTranslator} + * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 728 + */ +export const translator = NodeTranslator.from(createSingleIntegerPropertyHandler('w:startOverride')); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/startOverride/startOverride-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/startOverride/startOverride-translator.test.js new file mode 100644 index 0000000000..20fa946ae0 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/startOverride/startOverride-translator.test.js @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { translator } from './startOverride-translator.js'; + +describe('w:startOverride translator', () => { + describe('encode', () => { + it('extracts the w:val attribute', () => { + const result = translator.encode({ nodes: [{ attributes: { 'w:val': '3' } }] }); + expect(result).toBe(3); + }); + + it('returns undefined if w:val is missing', () => { + const result = translator.encode({ nodes: [{ attributes: {} }] }); + expect(result).toBeUndefined(); + }); + }); + + describe('decode', () => { + it('creates a w:startOverride element with the value in w:val', () => { + const { attributes: result } = translator.decode({ node: { attrs: { startOverride: 3 } } }); + expect(result).toEqual({ 'w:val': '3' }); + }); + + it('returns undefined if startOverride property is missing', () => { + const result = translator.decode({ node: { attrs: {} } }); + expect(result).toBeUndefined(); + }); + }); + + it('has correct metadata', () => { + expect(translator.xmlName).toBe('w:startOverride'); + expect(translator.sdNodeOrKeyName).toBe('startOverride'); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/style/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/style/index.js new file mode 100644 index 0000000000..0afc3517e0 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/style/index.js @@ -0,0 +1 @@ +export * from './style-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/style/style-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/style/style-translator.js new file mode 100644 index 0000000000..a2e6adf8df --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/style/style-translator.js @@ -0,0 +1,132 @@ +import { NodeTranslator } from '@translator'; +import { + createAttributeHandler, + createBooleanAttributeHandler, + encodeProperties, + decodeProperties, + encodePropertiesByKey, + decodePropertiesByKey, +} from '@converter/v3/handlers/utils.js'; +import { translator as wNameTranslator } from '../../w/name'; +import { translator as wAliasesTranslator } from '../../w/aliases'; +import { translator as wBasedOnTranslator } from '../../w/basedOn'; +import { translator as wNextTranslator } from '../../w/next'; +import { translator as wLinkTranslator } from '../../w/link'; +import { translator as wAutoRedefineTranslator } from '../../w/autoRedefine'; +import { translator as wHiddenTranslator } from '../../w/hidden'; +import { translator as wSemiHiddenTranslator } from '../../w/semiHidden'; +import { translator as wUnhideWhenUsedTranslator } from '../../w/unhideWhenUsed'; +import { translator as wQFormatTranslator } from '../../w/qFormat'; +import { translator as wLockedTranslator } from '../../w/locked'; +import { translator as wPersonalTranslator } from '../../w/personal'; +import { translator as wPersonalComposeTranslator } from '../../w/personalCompose'; +import { translator as wPersonalReplyTranslator } from '../../w/personalReply'; +import { translator as wUiPriorityTranslator } from '../../w/uiPriority'; +import { translator as wRsidTranslator } from '../../w/rsid'; +import { translator as wPPrTranslator } from '../../w/pPr'; +import { translator as wRPrTranslator } from '../../w/rpr'; +import { translator as wTblPrTranslator } from '../../w/tblPr'; +import { translator as wTrPrTranslator } from '../../w/trPr'; +import { translator as wTcPrTranslator } from '../../w/tcPr'; +import { translator as wTblStylePrTranslator } from '../../w/tblStylePr'; + +// Property translators for w:style child elements +// Each translator handles a specific property +/** @type {import('@translator').NodeTranslator[]} */ +const propertyTranslators = [ + wNameTranslator, + wAliasesTranslator, + wBasedOnTranslator, + wNextTranslator, + wLinkTranslator, + wAutoRedefineTranslator, + wHiddenTranslator, + wSemiHiddenTranslator, + wUnhideWhenUsedTranslator, + wQFormatTranslator, + wLockedTranslator, + wPersonalTranslator, + wPersonalComposeTranslator, + wPersonalReplyTranslator, + wUiPriorityTranslator, + wRsidTranslator, + wPPrTranslator, + wRPrTranslator, + wTblPrTranslator, + wTrPrTranslator, + wTcPrTranslator, + wTblStylePrTranslator, +]; + +const attributeHandlers = [ + createAttributeHandler('w:type'), + createAttributeHandler('w:styleId'), + createBooleanAttributeHandler('w:default'), + createBooleanAttributeHandler('w:customStyle'), +]; + +const propertyTranslatorsByXmlName = {}; +const propertyTranslatorsBySdName = {}; +propertyTranslators.forEach((translator) => { + if (!translator) return; + propertyTranslatorsByXmlName[translator.xmlName] = translator; + propertyTranslatorsBySdName[translator.sdNodeOrKeyName] = translator; +}); + +/** + * The NodeTranslator instance for the w:style element. + * @type {import('@translator').NodeTranslator} + */ +export const translator = NodeTranslator.from({ + xmlName: 'w:style', + sdNodeOrKeyName: 'style', + type: NodeTranslator.translatorTypes.NODE, + attributes: attributeHandlers, + encode: (params, encodedAttrs) => { + const { nodes } = params; + const node = nodes[0]; + + const result = { + ...encodedAttrs, + ...encodeProperties(params, propertyTranslatorsByXmlName), + ...encodePropertiesByKey( + wTblStylePrTranslator.xmlName, + 'tableStyleProperties', + wTblStylePrTranslator, + params, + node, + 'type', + ), + }; + + return result; + }, + decode: function (params) { + const currentValue = params.node.attrs?.['style']; + if (!currentValue) { + return undefined; + } + + const decodedAttrs = this.decodeAttributes({ node: { ...params.node, attrs: currentValue } }); + + const elements = [ + ...decodeProperties(params, propertyTranslatorsBySdName, currentValue), + ...decodePropertiesByKey( + wTblStylePrTranslator.xmlName, + 'tableStyleProperties', + wTblStylePrTranslator, + params, + currentValue, + ), + ]; + + const newNode = { + name: 'w:style', + type: 'element', + attributes: decodedAttrs, + elements: elements, + }; + + return newNode; + }, +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/style/style-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/style/style-translator.test.js new file mode 100644 index 0000000000..06d23fb5ad --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/style/style-translator.test.js @@ -0,0 +1,113 @@ +import { describe, it, expect } from 'vitest'; +import { translator } from './style-translator.js'; +import { NodeTranslator } from '@translator'; + +describe('w:style translator', () => { + describe('config', () => { + it('should have correct properties', () => { + expect(translator.xmlName).toBe('w:style'); + expect(translator.sdNodeOrKeyName).toBe('style'); + expect(translator).toBeInstanceOf(NodeTranslator); + }); + }); + + describe('encode', () => { + it('should encode nested style properties correctly', () => { + const xmlNode = { + name: 'w:style', + attributes: { + 'w:type': 'paragraph', + 'w:styleId': 'CustomStyle', + 'w:default': '1', + 'w:customStyle': '0', + }, + elements: [ + { name: 'w:name', attributes: { 'w:val': 'Custom Style' } }, + { name: 'w:aliases', attributes: { 'w:val': 'Alias1,Alias2' } }, + { name: 'w:uiPriority', attributes: { 'w:val': '1' } }, + { name: 'w:pPr', elements: [{ name: 'w:keepNext' }] }, + { name: 'w:rPr', elements: [{ name: 'w:b' }] }, + { + name: 'w:tblStylePr', + attributes: { 'w:type': 'firstRow' }, + elements: [], + }, + ], + }; + + const result = translator.encode({ nodes: [xmlNode] }); + + expect(result).toEqual({ + type: 'paragraph', + styleId: 'CustomStyle', + default: true, + customStyle: false, + name: 'Custom Style', + aliases: 'Alias1,Alias2', + uiPriority: 1, + paragraphProperties: { keepNext: true }, + runProperties: { bold: true }, + tableStyleProperties: { + firstRow: { + type: 'firstRow', + }, + }, + }); + }); + + it('should return an empty object if no attributes or child properties are present', () => { + const xmlNode = { name: 'w:style', elements: [] }; + const result = translator.encode({ nodes: [xmlNode] }); + expect(result).toEqual({}); + }); + }); + + describe('decode', () => { + it('should decode a style object correctly', () => { + const superDocNode = { + attrs: { + style: { + type: 'paragraph', + styleId: 'CustomStyle', + default: true, + customStyle: false, + name: 'Custom Style', + aliases: 'Alias1,Alias2', + uiPriority: 1, + paragraphProperties: { keepNext: true }, + runProperties: { bold: true }, + tableStyleProperties: { + firstRow: { + type: 'firstRow', + }, + }, + }, + }, + }; + + const result = translator.decode({ node: superDocNode }); + + expect(result.name).toBe('w:style'); + expect(result.attributes).toEqual({ + 'w:type': 'paragraph', + 'w:styleId': 'CustomStyle', + 'w:default': '1', + 'w:customStyle': '0', + }); + expect(result.elements).toEqual( + expect.arrayContaining([ + { name: 'w:name', attributes: { 'w:val': 'Custom Style' } }, + { name: 'w:aliases', attributes: { 'w:val': 'Alias1,Alias2' } }, + { name: 'w:uiPriority', attributes: { 'w:val': '1' } }, + { name: 'w:pPr', type: 'element', attributes: {}, elements: [{ name: 'w:keepNext', attributes: {} }] }, + { name: 'w:rPr', type: 'element', attributes: {}, elements: [{ name: 'w:b', attributes: {} }] }, + ]), + ); + }); + + it('should return undefined if no style properties are present', () => { + const result = translator.decode({ node: { attrs: {} } }); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/styleLink/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/styleLink/index.js new file mode 100644 index 0000000000..ba4a7a4eac --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/styleLink/index.js @@ -0,0 +1 @@ +export * from './styleLink-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/styleLink/styleLink-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/styleLink/styleLink-translator.js new file mode 100644 index 0000000000..49519e3fd1 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/styleLink/styleLink-translator.js @@ -0,0 +1,9 @@ +import { NodeTranslator } from '@translator'; +import { createSingleAttrPropertyHandler } from '../../utils.js'; + +/** + * The NodeTranslator instance for the styleLink element. + * @type {import('@translator').NodeTranslator} + * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 730 + */ +export const translator = NodeTranslator.from(createSingleAttrPropertyHandler('w:styleLink')); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/styleLink/styleLink-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/styleLink/styleLink-translator.test.js new file mode 100644 index 0000000000..c29eceffbc --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/styleLink/styleLink-translator.test.js @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { translator } from './styleLink-translator.js'; + +describe('w:styleLink translator', () => { + describe('encode', () => { + it('extracts the w:val attribute', () => { + const result = translator.encode({ nodes: [{ attributes: { 'w:val': 'LinkedStyle' } }] }); + expect(result).toBe('LinkedStyle'); + }); + + it('returns undefined if w:val is missing', () => { + const result = translator.encode({ nodes: [{ attributes: {} }] }); + expect(result).toBeUndefined(); + }); + }); + + describe('decode', () => { + it('creates a w:styleLink element with the value in w:val', () => { + const { attributes: result } = translator.decode({ node: { attrs: { styleLink: 'LinkedStyle' } } }); + expect(result).toEqual({ 'w:val': 'LinkedStyle' }); + }); + + it('returns undefined if styleLink property is missing', () => { + const result = translator.decode({ node: { attrs: {} } }); + expect(result).toBeUndefined(); + }); + }); + + it('has correct metadata', () => { + expect(translator.xmlName).toBe('w:styleLink'); + expect(translator.sdNodeOrKeyName).toBe('styleLink'); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/styles/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/styles/index.js new file mode 100644 index 0000000000..dfbcd6d82d --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/styles/index.js @@ -0,0 +1 @@ +export * from './styles-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/styles/styles-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/styles/styles-translator.js new file mode 100644 index 0000000000..604c9d0c6d --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/styles/styles-translator.js @@ -0,0 +1,60 @@ +import { NodeTranslator } from '@translator'; +import { translator as wDocDefaultsTranslator } from '../../w/docDefaults'; +import { translator as wLatentStylesTranslator } from '../../w/latentStyles'; +import { translator as wStyleTranslator } from '../../w/style'; +import { + encodeProperties, + decodeProperties, + encodePropertiesByKey, + decodePropertiesByKey, +} from '@converter/v3/handlers/utils.js'; + +/** + * The NodeTranslator instance for the w:styles element. + * @type {import('@translator').NodeTranslator} + */ +export const translator = NodeTranslator.from({ + xmlName: 'w:styles', + sdNodeOrKeyName: 'styles', + type: NodeTranslator.translatorTypes.NODE, + attributes: [], + encode: (params) => { + const { nodes } = params; + const node = nodes[0]; + + const props = encodeProperties(params, { + 'w:docDefaults': wDocDefaultsTranslator, + 'w:latentStyles': wLatentStylesTranslator, + }); + const result = { + ...props, + ...encodePropertiesByKey('w:style', 'styles', wStyleTranslator, params, node, 'styleId'), + }; + + return result; + }, + decode: function (params) { + const currentValue = params.node.attrs?.['styles']; + if (!currentValue) { + return undefined; + } + + const props = decodeProperties( + params, + { + docDefaults: wDocDefaultsTranslator, + latentStyles: wLatentStylesTranslator, + }, + currentValue, + ); + const elements = [...props, ...decodePropertiesByKey('w:style', 'styles', wStyleTranslator, params, currentValue)]; + const newNode = { + name: 'w:styles', + type: 'element', + attributes: {}, + elements: elements, + }; + + return newNode; + }, +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/styles/styles-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/styles/styles-translator.test.js new file mode 100644 index 0000000000..9284357d29 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/styles/styles-translator.test.js @@ -0,0 +1,231 @@ +import { describe, it, expect } from 'vitest'; +import { translator } from './styles-translator.js'; +import { NodeTranslator } from '@translator'; + +describe('w:styles translator', () => { + describe('config', () => { + it('should have correct properties', () => { + expect(translator.xmlName).toBe('w:styles'); + expect(translator.sdNodeOrKeyName).toBe('styles'); + expect(translator).toBeInstanceOf(NodeTranslator); + }); + }); + + describe('encode', () => { + it('should encode docDefaults, latentStyles, and styles correctly', () => { + const xmlNode = { + name: 'w:styles', + elements: [ + { + name: 'w:latentStyles', + attributes: { + 'w:defLockedState': '1', + 'w:defUIPriority': '1', + 'w:defSemiHidden': '0', + 'w:defUnhideWhenUsed': '1', + 'w:defQFormat': '1', + }, + elements: [ + { + name: 'w:lsdException', + attributes: { + 'w:name': 'NoList', + 'w:locked': '1', + 'w:qFormat': '1', + 'w:semiHidden': '0', + 'w:unhideWhenUsed': '1', + 'w:uiPriority': '99', + }, + }, + ], + }, + { + name: 'w:docDefaults', + elements: [ + { + name: 'w:rPrDefault', + elements: [{ name: 'w:rPr', elements: [{ name: 'w:b' }] }], + }, + { + name: 'w:pPrDefault', + elements: [{ name: 'w:pPr', elements: [{ name: 'w:keepNext' }] }], + }, + ], + }, + { + name: 'w:style', + attributes: { 'w:type': 'paragraph', 'w:styleId': 'Heading1' }, + elements: [{ name: 'w:name', attributes: { 'w:val': 'Heading 1' } }], + }, + { + name: 'w:style', + attributes: { 'w:type': 'character', 'w:styleId': 'Emphasis' }, + elements: [{ name: 'w:rPr', elements: [{ name: 'w:b' }] }], + }, + ], + }; + + const result = translator.encode({ nodes: [xmlNode] }); + + expect(result).toEqual({ + docDefaults: { + runProperties: { bold: true }, + paragraphProperties: { keepNext: true }, + }, + latentStyles: { + lsdExceptions: { + NoList: { + name: 'NoList', + locked: true, + qFormat: true, + semiHidden: false, + unhideWhenUsed: true, + uiPriority: 99, + }, + }, + defLockedState: true, + defUIPriority: true, + defSemiHidden: false, + defUnhideWhenUsed: true, + defQFormat: true, + }, + styles: { + Heading1: { + type: 'paragraph', + styleId: 'Heading1', + name: 'Heading 1', + }, + Emphasis: { + type: 'character', + styleId: 'Emphasis', + runProperties: { bold: true }, + }, + }, + }); + }); + + it('should return an empty object when no elements are present', () => { + const xmlNode = { name: 'w:styles', elements: [] }; + const result = translator.encode({ nodes: [xmlNode] }); + expect(result).toEqual({}); + }); + }); + + describe('decode', () => { + it('should decode styles into w:styles elements', () => { + const superDocNode = { + attrs: { + styles: { + docDefaults: { + runProperties: { bold: true }, + paragraphProperties: { keepNext: true }, + }, + latentStyles: { + lsdExceptions: { + NoList: { + name: 'NoList', + locked: true, + qFormat: true, + semiHidden: false, + unhideWhenUsed: true, + uiPriority: 99, + }, + }, + defLockedState: true, + defUIPriority: true, + defSemiHidden: false, + defUnhideWhenUsed: true, + defQFormat: true, + }, + styles: { + Heading1: { + type: 'paragraph', + styleId: 'Heading1', + name: 'Heading 1', + }, + Emphasis: { + type: 'character', + styleId: 'Emphasis', + runProperties: { bold: true }, + }, + }, + }, + }, + }; + + const result = translator.decode({ node: superDocNode }); + + expect(result.name).toBe('w:styles'); + expect(result.elements).toEqual( + expect.arrayContaining([ + { + name: 'w:docDefaults', + type: 'element', + attributes: {}, + elements: expect.arrayContaining([ + { + name: 'w:rPrDefault', + type: 'element', + elements: [ + { name: 'w:rPr', type: 'element', attributes: {}, elements: [{ name: 'w:b', attributes: {} }] }, + ], + }, + { + name: 'w:pPrDefault', + type: 'element', + elements: [ + { + name: 'w:pPr', + type: 'element', + attributes: {}, + elements: [{ name: 'w:keepNext', attributes: {} }], + }, + ], + }, + ]), + }, + { + name: 'w:latentStyles', + attributes: { + 'w:defLockedState': '1', + 'w:defUIPriority': '1', + 'w:defSemiHidden': '0', + 'w:defUnhideWhenUsed': '1', + 'w:defQFormat': '1', + }, + elements: [ + { + name: 'w:lsdException', + attributes: { + 'w:name': 'NoList', + 'w:locked': '1', + 'w:qFormat': '1', + 'w:semiHidden': '0', + 'w:unhideWhenUsed': '1', + 'w:uiPriority': '99', + }, + }, + ], + }, + { + name: 'w:style', + type: 'element', + attributes: { 'w:type': 'paragraph', 'w:styleId': 'Heading1' }, + elements: [{ name: 'w:name', attributes: { 'w:val': 'Heading 1' } }], + }, + { + name: 'w:style', + type: 'element', + attributes: { 'w:type': 'character', 'w:styleId': 'Emphasis' }, + elements: [{ name: 'w:rPr', type: 'element', attributes: {}, elements: [{ name: 'w:b', attributes: {} }] }], + }, + ]), + ); + }); + + it('should return undefined if no styles are present', () => { + const result = translator.decode({ node: { attrs: {} } }); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/suff/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/suff/index.js new file mode 100644 index 0000000000..e08995267e --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/suff/index.js @@ -0,0 +1 @@ +export * from './suff-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/suff/suff-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/suff/suff-translator.js new file mode 100644 index 0000000000..5ab9b83acb --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/suff/suff-translator.js @@ -0,0 +1,9 @@ +import { NodeTranslator } from '@translator'; +import { createSingleAttrPropertyHandler } from '../../utils.js'; + +/** + * The NodeTranslator instance for the suff element. + * @type {import('@translator').NodeTranslator} + * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 732 + */ +export const translator = NodeTranslator.from(createSingleAttrPropertyHandler('w:suff')); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/suff/suff-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/suff/suff-translator.test.js new file mode 100644 index 0000000000..ef1d7d949a --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/suff/suff-translator.test.js @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { translator } from './suff-translator.js'; + +describe('w:suff translator', () => { + describe('encode', () => { + it('extracts the w:val attribute', () => { + const result = translator.encode({ nodes: [{ attributes: { 'w:val': 'tab' } }] }); + expect(result).toBe('tab'); + }); + + it('returns undefined if w:val is missing', () => { + const result = translator.encode({ nodes: [{ attributes: {} }] }); + expect(result).toBeUndefined(); + }); + }); + + describe('decode', () => { + it('creates a w:suff element with the value in w:val', () => { + const { attributes: result } = translator.decode({ node: { attrs: { suff: 'tab' } } }); + expect(result).toEqual({ 'w:val': 'tab' }); + }); + + it('returns undefined if suff property is missing', () => { + const result = translator.decode({ node: { attrs: {} } }); + expect(result).toBeUndefined(); + }); + }); + + it('has correct metadata', () => { + expect(translator.xmlName).toBe('w:suff'); + expect(translator.sdNodeOrKeyName).toBe('suff'); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tblBorders/tblBorders-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tblBorders/tblBorders-translator.js index 926ff90555..758798b52c 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tblBorders/tblBorders-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tblBorders/tblBorders-translator.js @@ -11,7 +11,7 @@ import { translator as wTopTranslator } from '../top'; // Property translators for w:tblBorders child elements // Each translator handles a specific border property of the table -/** @type {import('@translator').NodeTranslatorConfig[]} */ +/** @type {import('@translator').NodeTranslator[]} */ const propertyTranslators = [ wBottomTranslator, wEndTranslator, diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tblPr/tblPr-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tblPr/tblPr-translator.js index e92cfd76f8..d94481dbc7 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tblPr/tblPr-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tblPr/tblPr-translator.js @@ -22,7 +22,7 @@ import { translator as tblCellMarTranslator } from '../tblCellMar'; // Property translators for w:tblPr child elements // Each translator handles a specific property of the table -/** @type {import('@translator').NodeTranslatorConfig[]} */ +/** @type {import('@translator').NodeTranslator[]} */ const propertyTranslators = [ bidiVisualTranslator, jcTranslator, diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tblStyleColBandSize/tblStyleColBandSize-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tblStyleColBandSize/tblStyleColBandSize-translator.js index 63c61e39e4..3d9465f1cf 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tblStyleColBandSize/tblStyleColBandSize-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tblStyleColBandSize/tblStyleColBandSize-translator.js @@ -1,5 +1,5 @@ import { NodeTranslator } from '@translator'; -import { createSingleAttrPropertyHandler } from '../../utils.js'; +import { createSingleIntegerPropertyHandler } from '../../utils.js'; /** * The NodeTranslator instance for the tblStyleColBandSize element. @@ -7,5 +7,5 @@ import { createSingleAttrPropertyHandler } from '../../utils.js'; * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 657 */ export const translator = NodeTranslator.from( - createSingleAttrPropertyHandler('w:tblStyleColBandSize', 'tableStyleColBandSize'), + createSingleIntegerPropertyHandler('w:tblStyleColBandSize', 'tableStyleColBandSize'), ); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tblStyleColBandSize/tblStyleColBandSize-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tblStyleColBandSize/tblStyleColBandSize-translator.test.js index 27ba8fedd2..7074633ef7 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tblStyleColBandSize/tblStyleColBandSize-translator.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tblStyleColBandSize/tblStyleColBandSize-translator.test.js @@ -5,7 +5,7 @@ describe('w:tblStyleColBandSize translator', () => { describe('encode', () => { it('extracts the w:val attribute', () => { const result = translator.encode({ nodes: [{ attributes: { 'w:val': '1' } }] }); - expect(result).toBe('1'); + expect(result).toBe(1); }); it('returns undefined if w:val is missing', () => { @@ -16,7 +16,7 @@ describe('w:tblStyleColBandSize translator', () => { describe('decode', () => { it('creates a w:tblStyleColBandSize element with the value in w:val', () => { - const { attributes: result } = translator.decode({ node: { attrs: { tableStyleColBandSize: '2' } } }); + const { attributes: result } = translator.decode({ node: { attrs: { tableStyleColBandSize: 2 } } }); expect(result).toEqual({ 'w:val': '2' }); }); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tblStylePr/tblStylePr-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tblStylePr/tblStylePr-translator.js index 97d6543d97..a5b19291af 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tblStylePr/tblStylePr-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tblStylePr/tblStylePr-translator.js @@ -1,15 +1,24 @@ +// @ts-check import { NodeTranslator } from '@translator'; -import { translator as tblPrTranslator } from '@converter/v3/handlers/w/tblPr'; -import { translator as tcPrTranslator } from '@converter/v3/handlers/w/tcPr'; -import { createNestedPropertiesTranslator } from '@converter/v3/handlers/utils.js'; +import { createNestedPropertiesTranslator, createAttributeHandler } from '@converter/v3/handlers/utils.js'; -/** @type {import('@translator').NodeTranslatorConfig[]} */ -const propertyTranslators = [tblPrTranslator, tcPrTranslator]; +import { translator as wPPrTranslator } from '../../w/pPr'; +import { translator as wRPrTranslator } from '../../w/rpr'; +import { translator as wTblPrTranslator } from '../../w/tblPr'; +import { translator as wTrPrTranslator } from '../../w/trPr'; +import { translator as wTcPrTranslator } from '../../w/tcPr'; + +// Property translators for w:tblStylePr child elements +// Each translator handles a specific property +/** @type {import('@translator').NodeTranslator[]} */ +const propertyTranslators = [wPPrTranslator, wRPrTranslator, wTblPrTranslator, wTrPrTranslator, wTcPrTranslator]; + +const attributeHandlers = [createAttributeHandler('w:type')]; /** * The NodeTranslator instance for the w:tblStylePr element. * @type {import('@translator').NodeTranslator} */ export const translator = NodeTranslator.from( - createNestedPropertiesTranslator('w:tblStylePr', 'tableStyleProperties', propertyTranslators), + createNestedPropertiesTranslator('w:tblStylePr', 'tableStyleProperties', propertyTranslators, {}, attributeHandlers), ); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tblStylePr/tblStylePr-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tblStylePr/tblStylePr-translator.test.js index 4022be0f33..b1947cc5c9 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tblStylePr/tblStylePr-translator.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tblStylePr/tblStylePr-translator.test.js @@ -1,172 +1,127 @@ import { describe, it, expect } from 'vitest'; -import { NodeTranslator } from '@translator'; import { translator } from './tblStylePr-translator.js'; +import { NodeTranslator } from '@translator'; describe('w:tblStylePr translator', () => { describe('config', () => { - it('exports a NodeTranslator instance', () => { - expect(translator).toBeDefined(); - expect(translator).toBeInstanceOf(NodeTranslator); + it('should have correct properties', () => { expect(translator.xmlName).toBe('w:tblStylePr'); expect(translator.sdNodeOrKeyName).toBe('tableStyleProperties'); + expect(translator).toBeInstanceOf(NodeTranslator); }); }); describe('encode', () => { - it('encodes nested and correctly', () => { - const params = { - nodes: [ + it('should encode nested table style properties correctly', () => { + const xmlNode = { + name: 'w:tblStylePr', + attributes: { 'w:type': 'wholeTable' }, + elements: [ + { + name: 'w:pPr', + elements: [{ name: 'w:keepNext' }, { name: 'w:pStyle', attributes: { 'w:val': 'Heading1' } }], + }, + { name: 'w:rPr', elements: [{ name: 'w:b' }] }, + { name: 'w:tblPr', elements: [{ name: 'w:tblStyle', attributes: { 'w:val': 'TableGrid' } }] }, { - name: 'w:tblStylePr', + name: 'w:trPr', elements: [ - { - name: 'w:tblPr', - elements: [ - { name: 'w:tblStyle', attributes: { 'w:val': 'TableGrid' } }, - { name: 'w:tblW', attributes: { 'w:w': '5000', 'w:type': 'pct' } }, - { name: 'w:jc', attributes: { 'w:val': 'center' } }, - ], - }, - { - name: 'w:tcPr', - elements: [ - { name: 'w:tcW', attributes: { 'w:w': '2000', 'w:type': 'dxa' } }, - { name: 'w:gridSpan', attributes: { 'w:val': '2' } }, - { name: 'w:noWrap' }, - ], - }, + { name: 'w:tblHeader' }, + { name: 'w:trHeight', attributes: { 'w:val': '240', 'w:hRule': 'atLeast' } }, ], }, + { name: 'w:tcPr', elements: [{ name: 'w:vAlign', attributes: { 'w:val': 'center' } }] }, ], }; - const result = translator.encode(params); + const result = translator.encode({ nodes: [xmlNode] }); expect(result).toEqual({ - tableProperties: { - tableStyleId: 'TableGrid', - tableWidth: { value: 5000, type: 'pct' }, - justification: 'center', - }, - tableCellProperties: { - cellWidth: { value: 2000, type: 'dxa' }, - gridSpan: 2, - noWrap: true, + paragraphProperties: { keepNext: true, styleId: 'Heading1' }, + runProperties: { bold: true }, + tableProperties: { tableStyleId: 'TableGrid' }, + tableRowProperties: { + cantSplit: false, + hidden: false, + repeatHeader: true, + rowHeight: { value: 240, rule: 'atLeast' }, }, + tableCellProperties: { vAlign: 'center' }, + type: 'wholeTable', }); }); - it('returns undefined when no nested properties are encoded', () => { - const params = { - nodes: [ - { - name: 'w:tblStylePr', - elements: [ - { name: 'w:tblPr', elements: [{ name: 'w:tblW', attributes: {} }] }, - { name: 'w:tcPr', elements: [{ name: 'w:tcW', attributes: {} }] }, - ], - }, - ], - }; - - expect(translator.encode(params)).toBeUndefined(); - }); - - it('encodes when at least one nested property group is present', () => { - const params = { - nodes: [ - { - name: 'w:tblStylePr', - elements: [ - { - name: 'w:tblPr', - elements: [{ name: 'w:tblStyle', attributes: { 'w:val': 'TableGrid' } }], - }, - ], - }, - ], - }; - - expect(translator.encode(params)).toEqual({ - tableProperties: { tableStyleId: 'TableGrid' }, - }); + it('should return undefined if no child properties are present', () => { + const xmlNode = { name: 'w:tblStylePr', elements: [] }; + const result = translator.encode({ nodes: [xmlNode] }); + expect(result).toBeUndefined(); }); }); describe('decode', () => { - it('decodes a complex tableStyleProperties object correctly', () => { - const tableStyleProperties = { - tableProperties: { - tableStyleId: 'TableGrid', - tableWidth: { value: 5000, type: 'pct' }, - justification: 'center', - }, - tableCellProperties: { - cellWidth: { value: 2000, type: 'dxa' }, - gridSpan: 2, - noWrap: true, + it('should decode a tableStyleProperties object correctly', () => { + const superDocNode = { + attrs: { + tableStyleProperties: { + type: 'wholeTable', + paragraphProperties: { keepNext: true, styleId: 'Heading1' }, + runProperties: { bold: true }, + tableProperties: { tableStyleId: 'TableGrid' }, + tableRowProperties: { repeatHeader: true, rowHeight: { value: 240, rule: 'atLeast' } }, + tableCellProperties: { vAlign: 'center' }, + }, }, }; - const result = translator.decode({ node: { attrs: { tableStyleProperties } } }); + const result = translator.decode({ node: superDocNode }); - expect(result).toEqual({ - name: 'w:tblStylePr', - type: 'element', - attributes: {}, - elements: expect.arrayContaining([ + expect(result.name).toBe('w:tblStylePr'); + expect(result.attributes).toEqual({ 'w:type': 'wholeTable' }); + expect(result.elements).toEqual( + expect.arrayContaining([ + { + name: 'w:pPr', + type: 'element', + attributes: {}, + elements: [ + { name: 'w:keepNext', attributes: {} }, + { name: 'w:pStyle', attributes: { 'w:val': 'Heading1' } }, + ], + }, + { + name: 'w:rPr', + type: 'element', + attributes: {}, + elements: [{ name: 'w:b', attributes: {} }], + }, { name: 'w:tblPr', type: 'element', attributes: {}, - elements: expect.arrayContaining([ - { name: 'w:tblStyle', attributes: { 'w:val': 'TableGrid' } }, - { name: 'w:tblW', attributes: { 'w:w': '5000', 'w:type': 'pct' } }, - { name: 'w:jc', attributes: { 'w:val': 'center' } }, - ]), + elements: [{ name: 'w:tblStyle', attributes: { 'w:val': 'TableGrid' } }], + }, + { + name: 'w:trPr', + type: 'element', + attributes: {}, + elements: [ + { name: 'w:tblHeader', attributes: {} }, + { name: 'w:trHeight', attributes: { 'w:val': '240', 'w:hRule': 'atLeast' } }, + ], }, { name: 'w:tcPr', type: 'element', attributes: {}, - elements: expect.arrayContaining([ - { name: 'w:tcW', attributes: { 'w:w': '2000', 'w:type': 'dxa' } }, - { name: 'w:gridSpan', attributes: { 'w:val': '2' } }, - { name: 'w:noWrap', attributes: { 'w:val': '1' } }, - ]), + elements: [{ name: 'w:vAlign', attributes: { 'w:val': 'center' } }], }, ]), - }); - }); - - it('handles missing tableStyleProperties object', () => { - expect(translator.decode({ node: { attrs: {} } })).toBeUndefined(); - }); - - it('handles empty tableStyleProperties object', () => { - expect(translator.decode({ node: { attrs: { tableStyleProperties: {} } } })).toBeUndefined(); + ); }); - }); - - describe('round-trip', () => { - it('maintains consistency for a complex object', () => { - const tableStyleProperties = { - tableProperties: { - tableStyleId: 'TableGrid', - tableWidth: { value: 5000, type: 'pct' }, - justification: 'center', - }, - tableCellProperties: { - cellWidth: { value: 2000, type: 'dxa' }, - gridSpan: 2, - noWrap: true, - }, - }; - - const decodedResult = translator.decode({ node: { attrs: { tableStyleProperties } } }); - const encodedResult = translator.encode({ nodes: [decodedResult] }); - expect(encodedResult).toEqual(tableStyleProperties); + it('should return undefined if no tableStyleProperties are present', () => { + const result = translator.decode({ node: { attrs: {} } }); + expect(result).toBeUndefined(); }); }); }); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tblStyleRowBandSize/tblStyleRowBandSize-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tblStyleRowBandSize/tblStyleRowBandSize-translator.js index 710c3e471f..67cdbd08ac 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tblStyleRowBandSize/tblStyleRowBandSize-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tblStyleRowBandSize/tblStyleRowBandSize-translator.js @@ -1,5 +1,5 @@ import { NodeTranslator } from '@translator'; -import { createSingleAttrPropertyHandler } from '../../utils.js'; +import { createSingleIntegerPropertyHandler } from '../../utils.js'; /** * The NodeTranslator instance for the tblStyleRowBandSize element. @@ -7,5 +7,5 @@ import { createSingleAttrPropertyHandler } from '../../utils.js'; * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 660 */ export const translator = NodeTranslator.from( - createSingleAttrPropertyHandler('w:tblStyleRowBandSize', 'tableStyleRowBandSize'), + createSingleIntegerPropertyHandler('w:tblStyleRowBandSize', 'tableStyleRowBandSize'), ); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tblStyleRowBandSize/tblStyleRowBandSize-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tblStyleRowBandSize/tblStyleRowBandSize-translator.test.js index 5175fa4640..938b6cb4ee 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tblStyleRowBandSize/tblStyleRowBandSize-translator.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tblStyleRowBandSize/tblStyleRowBandSize-translator.test.js @@ -5,7 +5,7 @@ describe('w:tblStyleRowBandSize translator', () => { describe('encode', () => { it('extracts the w:val attribute', () => { const result = translator.encode({ nodes: [{ attributes: { 'w:val': '1' } }] }); - expect(result).toBe('1'); + expect(result).toBe(1); }); it('returns undefined if w:val is missing', () => { @@ -16,7 +16,7 @@ describe('w:tblStyleRowBandSize translator', () => { describe('decode', () => { it('creates a w:tblStyleRowBandSize element with the value in w:val', () => { - const { attributes: result } = translator.decode({ node: { attrs: { tableStyleRowBandSize: '2' } } }); + const { attributes: result } = translator.decode({ node: { attrs: { tableStyleRowBandSize: 2 } } }); expect(result).toEqual({ 'w:val': '2' }); }); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tcBorders/tcBorders-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tcBorders/tcBorders-translator.js index 77b7723cd5..61d0b4b686 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tcBorders/tcBorders-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tcBorders/tcBorders-translator.js @@ -13,7 +13,7 @@ import { translator as tr2blTranslator } from '@converter/v3/handlers/w/tr2bl'; // Property translators for w:tcBorders child elements // Each translator handles a specific border property of the table -/** @type {import('@translator').NodeTranslatorConfig[]} */ +/** @type {import('@translator').NodeTranslator[]} */ const propertyTranslators = [ wTopTranslator, wStartTranslator, diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tcMar/tcMar-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tcMar/tcMar-translator.js index d9207b00d0..387995a5ab 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tcMar/tcMar-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tcMar/tcMar-translator.js @@ -10,7 +10,7 @@ import { marginTopTranslator } from '../top/index.js'; // Property translators for w:tcMar child elements // Each translator handles a specific margin property of the table cell -/** @type {import('@translator').NodeTranslatorConfig[]} */ +/** @type {import('@translator').NodeTranslator[]} */ const propertyTranslators = [ marginBottomTranslator, marginEndTranslator, diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tcPr/tcPr-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tcPr/tcPr-translator.js index d7c57706e4..666bf0b0a9 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tcPr/tcPr-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tcPr/tcPr-translator.js @@ -17,7 +17,7 @@ import { translator as headersTranslator } from '@converter/v3/handlers/w/header // Property translators for w:tcPr child elements // Each translator handles a specific property of the table cell -/** @type {import('@translator').NodeTranslatorConfig[]} */ +/** @type {import('@translator').NodeTranslator[]} */ const propertyTranslators = [ cnfStyleTranslator, tcWTranslator, diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tmpl/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tmpl/index.js new file mode 100644 index 0000000000..176a3afa51 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tmpl/index.js @@ -0,0 +1 @@ +export * from './tmpl-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tmpl/tmpl-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tmpl/tmpl-translator.js new file mode 100644 index 0000000000..2618fb4dde --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tmpl/tmpl-translator.js @@ -0,0 +1,9 @@ +import { NodeTranslator } from '@translator'; +import { createSingleIntegerPropertyHandler } from '@converter/v3/handlers/utils'; + +/** + * The NodeTranslator instance for the w:tmpl element. + * @type {import('@translator').NodeTranslator} + * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 733 + */ +export const translator = NodeTranslator.from(createSingleIntegerPropertyHandler('w:tmpl')); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tmpl/tmpl-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tmpl/tmpl-translator.test.js new file mode 100644 index 0000000000..a4bede9053 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tmpl/tmpl-translator.test.js @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { translator } from './tmpl-translator.js'; + +describe('w:tmpl translator', () => { + describe('encode', () => { + it('extracts the w:val attribute', () => { + const result = translator.encode({ nodes: [{ attributes: { 'w:val': '1' } }] }); + expect(result).toBe(1); + }); + + it('returns undefined if w:val is missing', () => { + const result = translator.encode({ nodes: [{ attributes: {} }] }); + expect(result).toBeUndefined(); + }); + }); + + describe('decode', () => { + it('creates a w:tmpl element with the value in w:val', () => { + const { attributes: result } = translator.decode({ node: { attrs: { tmpl: 1 } } }); + expect(result).toEqual({ 'w:val': '1' }); + }); + + it('returns undefined if tmpl property is missing', () => { + const result = translator.decode({ node: { attrs: {} } }); + expect(result).toBeUndefined(); + }); + }); + + it('has correct metadata', () => { + expect(translator.xmlName).toBe('w:tmpl'); + expect(translator.sdNodeOrKeyName).toBe('tmpl'); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/trPr/trPr-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/trPr/trPr-translator.js index e416a38d8f..dd5cf5ed51 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/trPr/trPr-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/trPr/trPr-translator.js @@ -16,7 +16,7 @@ import { translator as trWBeforeTranslator } from '@converter/v3/handlers/w/wBef // Property translators for w:trPr child elements // Each translator handles a specific property of the table row -/** @type {import('@translator').NodeTranslatorConfig[]} */ +/** @type {import('@translator').NodeTranslator[]} */ const propertyTranslators = [ cantSplitTranslator, cnfStyleTranslator, diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/trPr/trPr-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/trPr/trPr-translator.test.js index 8fd3cdd8d6..8cc30f0ad5 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/trPr/trPr-translator.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/trPr/trPr-translator.test.js @@ -158,7 +158,6 @@ describe('w:trPr translator', () => { attrs: { tableRowProperties: { cantSplit: false, - hidden: false, repeatHeader: false, // other properties are undefined }, diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/uiPriority/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/uiPriority/index.js new file mode 100644 index 0000000000..3f780823d4 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/uiPriority/index.js @@ -0,0 +1 @@ +export * from './uiPriority-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/uiPriority/uiPriority-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/uiPriority/uiPriority-translator.js new file mode 100644 index 0000000000..1f00d2df75 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/uiPriority/uiPriority-translator.js @@ -0,0 +1,9 @@ +import { NodeTranslator } from '@translator'; +import { createSingleIntegerPropertyHandler } from '@converter/v3/handlers/utils'; + +/** + * The NodeTranslator instance for the w:uiPriority element. + * @type {import('@translator').NodeTranslator} + * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 645 + */ +export const translator = NodeTranslator.from(createSingleIntegerPropertyHandler('w:uiPriority')); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/uiPriority/uiPriority-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/uiPriority/uiPriority-translator.test.js new file mode 100644 index 0000000000..f7875b445d --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/uiPriority/uiPriority-translator.test.js @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { translator } from './uiPriority-translator.js'; + +describe('w:uiPriority translator', () => { + describe('encode', () => { + it('extracts the w:val attribute', () => { + const result = translator.encode({ nodes: [{ attributes: { 'w:val': '5' } }] }); + expect(result).toBe(5); + }); + + it('returns undefined if w:val is missing', () => { + const result = translator.encode({ nodes: [{ attributes: {} }] }); + expect(result).toBeUndefined(); + }); + }); + + describe('decode', () => { + it('creates a w:uiPriority element with the value in w:val', () => { + const { attributes: result } = translator.decode({ node: { attrs: { uiPriority: 5 } } }); + expect(result).toEqual({ 'w:val': '5' }); + }); + + it('returns undefined if uiPriority property is missing', () => { + const result = translator.decode({ node: { attrs: {} } }); + expect(result).toBeUndefined(); + }); + }); + + it('has correct metadata', () => { + expect(translator.xmlName).toBe('w:uiPriority'); + expect(translator.sdNodeOrKeyName).toBe('uiPriority'); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/unhideWhenUsed/index.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/unhideWhenUsed/index.js new file mode 100644 index 0000000000..223844f222 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/unhideWhenUsed/index.js @@ -0,0 +1 @@ +export * from './unhideWhenUsed-translator.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/unhideWhenUsed/unhideWhenUsed-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/unhideWhenUsed/unhideWhenUsed-translator.js new file mode 100644 index 0000000000..245e4fb997 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/unhideWhenUsed/unhideWhenUsed-translator.js @@ -0,0 +1,9 @@ +import { NodeTranslator } from '@translator'; +import { createSingleBooleanPropertyHandler } from '@converter/v3/handlers/utils'; + +/** + * The NodeTranslator instance for the unhideWhenUsed element. + * @type {import('@translator').NodeTranslator} + * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 646 + */ +export const translator = NodeTranslator.from(createSingleBooleanPropertyHandler('w:unhideWhenUsed')); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/unhideWhenUsed/unhideWhenUsed-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/unhideWhenUsed/unhideWhenUsed-translator.test.js new file mode 100644 index 0000000000..91f85a29aa --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/unhideWhenUsed/unhideWhenUsed-translator.test.js @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; + +import { translator } from './unhideWhenUsed-translator.js'; +import { NodeTranslator } from '../../../node-translator/node-translator.js'; + +describe('w:unhideWhenUsed translator (attribute)', () => { + it('exposes correct translator meta', () => { + expect(translator.xmlName).toBe('w:unhideWhenUsed'); + expect(translator.sdNodeOrKeyName).toBe('unhideWhenUsed'); + expect(typeof translator.encode).toBe('function'); + }); + + it('builds NodeTranslator instance', () => { + expect(translator).toBeInstanceOf(NodeTranslator); + expect(translator.xmlName).toBe('w:unhideWhenUsed'); + expect(translator.sdNodeOrKeyName).toBe('unhideWhenUsed'); + }); + + describe('encode', () => { + it('encodes with provided w:val as-is', () => { + const params = { nodes: [{ attributes: { 'w:val': '1' } }] }; + const out = translator.encode(params); + expect(out).toBe(true); + }); + + it('passes through raw attributes when missing encoded boolean', () => { + const params = { nodes: [{ attributes: {} }] }; + const out = translator.encode(params); + expect(out).toBe(true); + }); + }); +}); diff --git a/packages/super-editor/src/extensions/paragraph/ParagraphNodeView.js b/packages/super-editor/src/extensions/paragraph/ParagraphNodeView.js index be759f1e0d..45efd65b61 100644 --- a/packages/super-editor/src/extensions/paragraph/ParagraphNodeView.js +++ b/packages/super-editor/src/extensions/paragraph/ParagraphNodeView.js @@ -392,7 +392,10 @@ export class ParagraphNodeView { // START: modify after CSS styles const paragraphProperties = getResolvedParagraphProperties(this.node); const runProperties = resolveRunProperties( - { docx: this.editor.converter.convertedXml, numbering: this.editor.converter.numbering }, + { + translatedNumbering: this.editor.converter.translatedNumbering, + translatedLinkedStyles: this.editor.converter.translatedLinkedStyles, + }, paragraphProperties.runProperties || {}, paragraphProperties, true, diff --git a/packages/super-editor/src/extensions/paragraph/ParagraphNodeView.test.js b/packages/super-editor/src/extensions/paragraph/ParagraphNodeView.test.js index 7edb03a089..e0d8215244 100644 --- a/packages/super-editor/src/extensions/paragraph/ParagraphNodeView.test.js +++ b/packages/super-editor/src/extensions/paragraph/ParagraphNodeView.test.js @@ -64,6 +64,21 @@ const createEditor = () => { converter: { convertedXml: {}, numbering: {}, + translatedNumbering: {}, + translatedLinkedStyles: { + docDefaults: { runProperties: {}, paragraphProperties: {} }, + latentStyles: {}, + styles: { + Normal: { + styleId: 'Normal', + type: 'paragraph', + default: true, + name: 'Normal', + runProperties: {}, + paragraphProperties: {}, + }, + }, + }, }, state: { doc: { @@ -237,12 +252,10 @@ describe('ParagraphNodeView', () => { it('caches resolved paragraph properties', () => { const node = createNode(); const editor = createEditor(); - resolveParagraphProperties.mockReturnValue({ cached: true, numberingProperties: {} }); const first = calculateResolvedParagraphProperties(editor, node, {}); const second = calculateResolvedParagraphProperties(editor, node, {}); - expect(resolveParagraphProperties).toHaveBeenCalledTimes(1); expect(first).toBe(second); expect(getResolvedParagraphProperties(node)).toBe(first); }); diff --git a/packages/super-editor/src/extensions/paragraph/resolvedPropertiesCache.js b/packages/super-editor/src/extensions/paragraph/resolvedPropertiesCache.js index d0c74c0975..e4f87b6c58 100644 --- a/packages/super-editor/src/extensions/paragraph/resolvedPropertiesCache.js +++ b/packages/super-editor/src/extensions/paragraph/resolvedPropertiesCache.js @@ -1,4 +1,4 @@ -import { resolveParagraphProperties } from '@converter/styles.js'; +import { resolveParagraphProperties } from '@superdoc/style-engine/ooxml'; import { findParentNodeClosestToPos } from '@helpers/index.js'; const resolvedParagraphPropertiesCache = new WeakMap(); @@ -18,10 +18,11 @@ export function calculateResolvedParagraphProperties(editor, node, $pos) { const tableNode = findParentNodeClosestToPos($pos, (node) => node.type.name === 'table'); const tableStyleId = tableNode?.node.attrs.tableStyleId || null; const paragraphProperties = resolveParagraphProperties( - { docx: editor.converter.convertedXml, numbering: editor.converter.numbering }, + { + translatedNumbering: editor.converter.translatedNumbering, + translatedLinkedStyles: editor.converter.translatedLinkedStyles, + }, node.attrs.paragraphProperties || {}, - Boolean(tableNode), - false, tableStyleId, ); resolvedParagraphPropertiesCache.set(node, paragraphProperties); diff --git a/packages/super-editor/src/extensions/permission-ranges/permission-ranges.test.js b/packages/super-editor/src/extensions/permission-ranges/permission-ranges.test.js index 0c3966c921..8c5e47229d 100644 --- a/packages/super-editor/src/extensions/permission-ranges/permission-ranges.test.js +++ b/packages/super-editor/src/extensions/permission-ranges/permission-ranges.test.js @@ -2,7 +2,9 @@ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { TextSelection } from 'prosemirror-state'; import { Editor } from '@core/index.js'; +import { SuperConverter } from '@core/super-converter/SuperConverter.js'; import { getStarterExtensions } from '@extensions/index.js'; +import { getMinimalTranslatedLinkedStyles } from '@tests/helpers/helpers.js'; const VIEWING_MODE = 'viewing'; @@ -83,11 +85,16 @@ describe('PermissionRanges extension', () => { }); const createEditor = (content, extraOptions = {}) => { + const converter = new SuperConverter(); + converter.translatedLinkedStyles = getMinimalTranslatedLinkedStyles(); + converter.translatedNumbering = { abstracts: {}, definitions: {} }; + editor = new Editor({ extensions: getStarterExtensions(), - jsonOverride: content, + content, loadFromSchema: true, documentMode: VIEWING_MODE, + converter, ...extraOptions, }); return editor; diff --git a/packages/super-editor/src/extensions/run/calculateInlineRunPropertiesPlugin.js b/packages/super-editor/src/extensions/run/calculateInlineRunPropertiesPlugin.js index 9d5b31ee73..97aa6d4c3d 100644 --- a/packages/super-editor/src/extensions/run/calculateInlineRunPropertiesPlugin.js +++ b/packages/super-editor/src/extensions/run/calculateInlineRunPropertiesPlugin.js @@ -71,7 +71,10 @@ export const calculateInlineRunPropertiesPlugin = (editor) => getResolvedParagraphProperties(paragraphNode) || calculateResolvedParagraphProperties(editor, paragraphNode, $pos); const runPropertiesFromStyles = resolveRunProperties( - { docx: editor.converter?.convertedXml ?? {}, numbering: editor.converter?.numbering ?? {} }, + { + translatedNumbering: editor.converter?.translatedNumbering ?? {}, + translatedLinkedStyles: editor.converter?.translatedLinkedStyles ?? {}, + }, {}, paragraphProperties, false, diff --git a/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.js b/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.js index 4b2d478ea7..a0d2de8bea 100644 --- a/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.js +++ b/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.js @@ -107,7 +107,10 @@ const resolveRunPropertiesFromParagraphStyle = (paragraphNode, editor) => { if (!styleId) return {}; try { - const params = { docx: editor.converter.convertedXml, numbering: editor.converter.numbering }; + const params = { + translatedNumbering: editor.converter.translatedNumbering, + translatedLinkedStyles: editor.converter.translatedLinkedStyles, + }; const resolvedPpr = { styleId }; const runProps = resolveRunProperties(params, {}, resolvedPpr, false, false); diff --git a/packages/super-editor/src/extensions/tab/tab-alignments.test.js b/packages/super-editor/src/extensions/tab/tab-alignments.test.js index e72a4c8c4f..7b6923c2eb 100644 --- a/packages/super-editor/src/extensions/tab/tab-alignments.test.js +++ b/packages/super-editor/src/extensions/tab/tab-alignments.test.js @@ -52,7 +52,7 @@ describe('tab alignment calculations', () => { { type: 'paragraph', attrs: { - tabStops: [{ val: 'start', pos: 144 }], + paragraphProperties: { tabStops: [{ val: 'start', pos: 144 }] }, }, content: [makeText('Text'), makeTab(), makeText('After')], }, @@ -103,10 +103,12 @@ describe('tab alignment calculations', () => { { type: 'paragraph', attrs: { - tabStops: [ - { val: 'start', pos: 96 }, - { val: 'start', pos: 192 }, - ], + paragraphProperties: { + tabStops: [ + { val: 'start', pos: 96 }, + { val: 'start', pos: 192 }, + ], + }, }, content: [makeText('A'), makeTab(), makeText('B'), makeTab(), makeText('C')], }, @@ -136,7 +138,7 @@ describe('tab alignment calculations', () => { { type: 'paragraph', attrs: { - tabStops: [{ val: 'center', pos: 144 }], + paragraphProperties: { tabStops: [{ val: 'center', pos: 144 }] }, }, content: [makeText('Left'), makeTab(), makeText('Center')], }, @@ -164,7 +166,7 @@ describe('tab alignment calculations', () => { { type: 'paragraph', attrs: { - tabStops: [{ val: 'end', pos: 200 }], + paragraphProperties: { tabStops: [{ val: 'end', pos: 200 }] }, }, content: [makeText('Left'), makeTab(), makeText('Right')], }, @@ -190,7 +192,7 @@ describe('tab alignment calculations', () => { { type: 'paragraph', attrs: { - tabStops: [{ val: 'right', pos: 180 }], + paragraphProperties: { tabStops: [{ val: 'right', pos: 180 }] }, }, content: [makeText('Left'), makeTab(), makeText('Right Text')], }, @@ -218,7 +220,7 @@ describe('tab alignment calculations', () => { { type: 'paragraph', attrs: { - tabStops: [{ val: 'decimal', pos: 200 }], + paragraphProperties: { tabStops: [{ val: 'decimal', pos: 200 }] }, }, content: [makeText('Price: '), makeTab(), makeText('123.45')], }, @@ -244,7 +246,7 @@ describe('tab alignment calculations', () => { { type: 'paragraph', attrs: { - tabStops: [{ val: 'decimal', pos: 180 }], + paragraphProperties: { tabStops: [{ val: 'decimal', pos: 180 }] }, }, content: [makeText('Count: '), makeTab(), makeText('42')], }, @@ -272,7 +274,7 @@ describe('tab alignment calculations', () => { { type: 'paragraph', attrs: { - tabStops: [{ val: 'start', pos: 200, leader: 'dot' }], + paragraphProperties: { tabStops: [{ val: 'start', pos: 200, leader: 'dot' }] }, }, content: [makeText('Item'), makeTab(), makeText('Value')], }, @@ -299,7 +301,7 @@ describe('tab alignment calculations', () => { { type: 'paragraph', attrs: { - tabStops: [{ val: 'start', pos: 200, leader: 'heavy' }], + paragraphProperties: { tabStops: [{ val: 'start', pos: 200, leader: 'heavy' }] }, }, content: [makeText('Item'), makeTab(), makeText('Value')], }, diff --git a/packages/super-editor/src/tests/helpers/adapterTestHelpers.js b/packages/super-editor/src/tests/helpers/adapterTestHelpers.js index 140b03ce90..18586be647 100644 --- a/packages/super-editor/src/tests/helpers/adapterTestHelpers.js +++ b/packages/super-editor/src/tests/helpers/adapterTestHelpers.js @@ -44,10 +44,29 @@ export const buildStyleContextFromEditor = (editor) => { * Build ConverterContext from editor instance for paragraph hydration. */ export const buildConverterContextFromEditor = (editor) => { + const converter = editor.converter; + if (!converter) { + throw new Error('Editor does not have converter'); + } + return { - docx: editor.converter?.convertedXml, - numbering: editor.converter?.numbering, - styles: editor.converter?.convertedXml?.['word/styles.xml'], + docx: converter.convertedXml, + numbering: converter.numbering, + translatedNumbering: converter.translatedNumbering ?? {}, + translatedLinkedStyles: converter.translatedLinkedStyles ?? { + docDefaults: { runProperties: {}, paragraphProperties: {} }, + latentStyles: {}, + styles: { + Normal: { + styleId: 'Normal', + type: 'paragraph', + default: true, + name: 'Normal', + runProperties: {}, + paragraphProperties: {}, + }, + }, + }, }; }; diff --git a/packages/super-editor/src/tests/helpers/helpers.js b/packages/super-editor/src/tests/helpers/helpers.js index c0e3b19fef..9a551e71d2 100644 --- a/packages/super-editor/src/tests/helpers/helpers.js +++ b/packages/super-editor/src/tests/helpers/helpers.js @@ -4,6 +4,7 @@ import { parseXmlToJson } from '@converter/v2/docxHelper.js'; import { getStarterExtensions } from '@extensions/index.js'; import { Editor } from '@core/Editor.js'; import DocxZipper from '@core/DocxZipper.js'; +import { SuperConverter } from '@core/super-converter/SuperConverter.js'; const EXTENSIONS_TO_CONVERT = new Set(['.xml', '.rels']); @@ -88,6 +89,51 @@ export const loadTestDataForEditorTests = async (filename) => { return { docx, media, mediaFiles, fonts }; }; +export const getMinimalTranslatedLinkedStyles = () => ({ + docDefaults: { + runProperties: {}, + paragraphProperties: {}, + }, + latentStyles: {}, + styles: { + Normal: { + styleId: 'Normal', + type: 'paragraph', + default: true, + name: 'Normal', + runProperties: {}, + paragraphProperties: {}, + }, + }, +}); + +const createMinimalConverter = () => { + const converter = new SuperConverter(); + converter.translatedLinkedStyles = getMinimalTranslatedLinkedStyles(); + converter.translatedNumbering = {}; + return converter; +}; + +const ensureTranslatedLinkedStyles = (converter) => { + if (!converter) return; + + const fallback = getMinimalTranslatedLinkedStyles(); + const translated = converter.translatedLinkedStyles; + + if (!translated || typeof translated !== 'object') { + converter.translatedLinkedStyles = fallback; + return; + } + + translated.docDefaults ??= { ...fallback.docDefaults }; + translated.docDefaults.runProperties ??= {}; + translated.docDefaults.paragraphProperties ??= {}; + translated.latentStyles ??= {}; + translated.styles ??= {}; + translated.styles.Normal ??= { ...fallback.styles.Normal }; + translated.styles.Normal.styleId ??= 'Normal'; +}; + /** * Instantiate a new test editor instance and wait for it to be ready. * @@ -100,6 +146,22 @@ export const loadTestDataForEditorTests = async (filename) => { */ export const initTestEditor = (options = {}) => { const { onCreate: userOnCreate, element: providedElement, useImmediateSetTimeout = true, ...restOptions } = options; + const isSchemaContent = + restOptions.content && + typeof restOptions.content === 'object' && + !Array.isArray(restOptions.content) && + restOptions.content.type === 'doc'; + const shouldProvideConverter = + !restOptions.converter && + (restOptions.mode === 'text' || + restOptions.mode === 'html' || + restOptions.loadFromSchema || + typeof restOptions.content === 'string' || + isSchemaContent); + + if (shouldProvideConverter) { + restOptions.converter = createMinimalConverter(); + } const hasWindow = typeof window !== 'undefined' && window?.setTimeout; const originalSetTimeout = hasWindow ? window.setTimeout : null; @@ -132,6 +194,11 @@ export const initTestEditor = (options = {}) => { window.setTimeout = originalSetTimeout; } + if (!editor.converter && restOptions.converter) { + editor.converter = restOptions.converter; + } + ensureTranslatedLinkedStyles(editor.converter); + return { editor, dispatch: editor.dispatch.bind(editor), diff --git a/packages/super-editor/src/tests/import-export/footnotes-roundtrip.test.js b/packages/super-editor/src/tests/import-export/footnotes-roundtrip.test.js index 9e541d0f09..cfae5025f3 100644 --- a/packages/super-editor/src/tests/import-export/footnotes-roundtrip.test.js +++ b/packages/super-editor/src/tests/import-export/footnotes-roundtrip.test.js @@ -12,6 +12,20 @@ import { carbonCopy } from '@core/utilities/carbonCopy.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const DOCX_FIXTURE_NAME = 'basic-footnotes.docx'; +const minimalStylesXml = parseXmlToJson( + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '', +); // ============================================ // Helper Functions @@ -566,6 +580,7 @@ describe('customMarkFollows attribute', () => { const docx = { 'word/document.xml': documentXml, 'word/footnotes.xml': footnotesXml, + 'word/styles.xml': minimalStylesXml, }; // Import using createDocumentJson @@ -617,6 +632,7 @@ describe('w:footnotePr properties', () => { const docx = { 'word/document.xml': documentXml, 'word/settings.xml': settingsXml, + 'word/styles.xml': minimalStylesXml, }; const { createDocumentJson } = await import('@converter/v2/importer/docxImporter.js'); @@ -648,6 +664,7 @@ describe('w:footnotePr properties', () => { const docx = { 'word/document.xml': documentXml, + 'word/styles.xml': minimalStylesXml, }; const { createDocumentJson } = await import('@converter/v2/importer/docxImporter.js'); @@ -681,6 +698,7 @@ describe('w:footnotePr properties', () => { const docx = { 'word/document.xml': documentXml, 'word/settings.xml': settingsXml, + 'word/styles.xml': minimalStylesXml, }; const { createDocumentJson } = await import('@converter/v2/importer/docxImporter.js'); diff --git a/packages/super-editor/src/tests/import/docxImporter.test.js b/packages/super-editor/src/tests/import/docxImporter.test.js index dbc701c0b7..13015c6fff 100644 --- a/packages/super-editor/src/tests/import/docxImporter.test.js +++ b/packages/super-editor/src/tests/import/docxImporter.test.js @@ -5,6 +5,20 @@ import { parseXmlToJson } from '@converter/v2/docxHelper.js'; import { getTestDataByFileName } from '@tests/helpers/helpers.js'; import { extractParagraphText } from '@tests/helpers/getParagraphText.js'; +const minimalStylesXml = + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + describe('addDefaultStylesIfMissing', () => { const styles = { declaration: { @@ -53,6 +67,7 @@ describe('createDocumentJson', () => { const docx = { 'word/document.xml': parseXmlToJson(simpleDocXml), + 'word/styles.xml': parseXmlToJson(minimalStylesXml), }; const converter = { @@ -62,7 +77,7 @@ describe('createDocumentJson', () => { footerIds: {}, }; - const editor = { options: {} }; + const editor = { options: {}, emit: vi.fn() }; const result = createDocumentJson(docx, converter, editor); @@ -150,6 +165,7 @@ describe('createDocumentJson', () => { const docx = { 'word/document.xml': parseXmlToJson(simpleDocXml), + 'word/styles.xml': parseXmlToJson(minimalStylesXml), }; const converter = { @@ -206,7 +222,19 @@ describe('createDocumentJson', () => { const docXml = 'Hello world'; const stylesXml = - ''; + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; const docx = { 'word/document.xml': parseXmlToJson(docXml), diff --git a/packages/super-editor/src/tests/import/footnotesImporter.test.js b/packages/super-editor/src/tests/import/footnotesImporter.test.js index ac1c8152af..90fffdc7eb 100644 --- a/packages/super-editor/src/tests/import/footnotesImporter.test.js +++ b/packages/super-editor/src/tests/import/footnotesImporter.test.js @@ -2,6 +2,20 @@ import { describe, expect, it } from 'vitest'; import { createDocumentJson } from '@core/super-converter/v2/importer/docxImporter'; import { parseXmlToJson } from '@converter/v2/docxHelper.js'; +const minimalStylesXml = + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + const collectNodeTypes = (node, types = []) => { if (!node) return types; if (typeof node.type === 'string') types.push(node.type); @@ -48,6 +62,7 @@ describe('footnotes import', () => { const docx = { 'word/document.xml': parseXmlToJson(documentXml), 'word/footnotes.xml': parseXmlToJson(footnotesXml), + 'word/styles.xml': parseXmlToJson(minimalStylesXml), }; const converter = { headers: {}, footers: {}, headerIds: {}, footerIds: {}, docHiglightColors: new Set() }; diff --git a/packages/super-editor/src/tests/import/hyperlinkImporter.test.js b/packages/super-editor/src/tests/import/hyperlinkImporter.test.js index eb9cac3cd6..dcd945aedd 100644 --- a/packages/super-editor/src/tests/import/hyperlinkImporter.test.js +++ b/packages/super-editor/src/tests/import/hyperlinkImporter.test.js @@ -1,6 +1,6 @@ import { hyperlinkNodeHandlerEntity } from '@converter/v2/importer/hyperlinkImporter.js'; import { getTestDataByFileName } from '@tests/helpers/helpers.js'; -import { defaultNodeListHandler } from '@converter/v2/importer/docxImporter.js'; +import { defaultNodeListHandler, translateStyleDefinitions } from '@converter/v2/importer/docxImporter.js'; describe('HyperlinkNodeImporter', () => { it('parses w:hyperlink with styles', async () => { @@ -11,11 +11,13 @@ describe('HyperlinkNodeImporter', () => { const doc = documentXml.elements[0]; const body = doc.elements[0]; const content = body.elements; + const translatedLinkedStyles = translateStyleDefinitions(docx); const { nodes } = hyperlinkNodeHandlerEntity.handler({ nodes: [content[1].elements[2]], docx, nodeListHandler: defaultNodeListHandler(), + translatedLinkedStyles, }); const runNode = nodes.find((node) => node.type === 'run') || nodes[0]; const textNode = runNode.content?.find((child) => child.type === 'text'); @@ -55,11 +57,13 @@ describe('HyperlinkNodeImporter', () => { const doc = documentXml.elements[0]; const body = doc.elements[0]; const content = body.elements; + const translatedLinkedStyles = translateStyleDefinitions(docx); const { nodes } = hyperlinkNodeHandlerEntity.handler({ nodes: [content[2].elements[1]], docx, nodeListHandler: defaultNodeListHandler(), + translatedLinkedStyles, }); const runNode = nodes.find((node) => node.type === 'run') || nodes[0]; const textNode = runNode.content?.find((child) => child.type === 'text'); @@ -92,11 +96,13 @@ describe('HyperlinkNodeImporter', () => { const doc = documentXml.elements[0]; const body = doc.elements[0]; const paragraph = body.elements[0]; + const translatedLinkedStyles = translateStyleDefinitions(docx); const { nodes } = hyperlinkNodeHandlerEntity.handler({ nodes: [paragraph.elements[0]], docx, nodeListHandler: defaultNodeListHandler(), + translatedLinkedStyles, }); const textSegments = nodes diff --git a/packages/super-editor/src/tests/import/runImporter.test.js b/packages/super-editor/src/tests/import/runImporter.test.js index 2928371760..a735e6ea67 100644 --- a/packages/super-editor/src/tests/import/runImporter.test.js +++ b/packages/super-editor/src/tests/import/runImporter.test.js @@ -65,9 +65,9 @@ const createMockDocx = (styles = []) => { return docx; }; -const createMockStyle = (styleId, runProperties = []) => ({ +const createMockStyle = (styleId, runProperties = [], type = 'paragraph') => ({ name: 'w:style', - attributes: { 'w:styleId': styleId }, + attributes: { 'w:styleId': styleId, 'w:type': type }, elements: [ { name: 'w:rPr', @@ -111,29 +111,109 @@ const createMockBold = () => createMockRunProperty('w:b', {}); const createMockItalic = () => createMockRunProperty('w:i', {}); +const parseRunProperties = (runProperties = []) => { + const resolved = {}; + runProperties.forEach((prop) => { + if (!prop || typeof prop !== 'object') return; + switch (prop.name) { + case 'w:rFonts': { + const ascii = prop.attributes?.['w:ascii']; + if (ascii) { + resolved.fontFamily = { + ascii, + hAnsi: prop.attributes?.['w:hAnsi'] || ascii, + eastAsia: prop.attributes?.['w:eastAsia'], + cs: prop.attributes?.['w:cs'], + }; + } + break; + } + case 'w:sz': { + const size = Number(prop.attributes?.['w:val']); + if (Number.isFinite(size)) resolved.fontSize = size; + break; + } + case 'w:color': { + const val = prop.attributes?.['w:val']; + if (val) resolved.color = { val }; + break; + } + case 'w:b': + resolved.bold = true; + break; + case 'w:i': + resolved.italic = true; + break; + default: + break; + } + }); + return resolved; +}; + +const buildTranslatedLinkedStyles = (styles = []) => { + const translated = { + docDefaults: { + runProperties: {}, + paragraphProperties: {}, + }, + latentStyles: {}, + styles: { + Normal: { + styleId: 'Normal', + type: 'paragraph', + default: true, + name: 'Normal', + runProperties: {}, + paragraphProperties: {}, + }, + }, + }; + + styles.forEach((style) => { + const styleId = style?.attributes?.['w:styleId']; + if (!styleId) return; + const type = style?.attributes?.['w:type'] || 'paragraph'; + const runPropsNode = style?.elements?.find((child) => child?.name === 'w:rPr'); + const runProps = parseRunProperties(runPropsNode?.elements ?? []); + translated.styles[styleId] = { + styleId, + type, + runProperties: runProps, + paragraphProperties: {}, + }; + }); + + return translated; +}; + describe('runImporter', () => { describe('runStyle attributes override paragraphStyleAttributes', () => { it('should override paragraph style attributes with run style attributes', () => { // Create styles with paragraph and run styles - const paragraphStyle = createMockStyle('ParagraphStyle', [ - createMockFont('Times New Roman'), - createMockSize('24'), // 12pt - ]); + const paragraphStyle = createMockStyle( + 'ParagraphStyle', + [createMockFont('Times New Roman'), createMockSize('24')], + 'paragraph', + ); - const runStyle = createMockStyle('RunStyle', [ - createMockFont('Arial'), - createMockSize('32'), // 16pt - ]); + const runStyle = createMockStyle('RunStyle', [createMockFont('Arial'), createMockSize('32')], 'character'); const mockDocx = createMockDocx([paragraphStyle, runStyle]); + const translatedLinkedStyles = buildTranslatedLinkedStyles([paragraphStyle, runStyle]); const mockRunNode = createMockRunNode([createMockRunStyle('RunStyle')]); const mockNodeListHandler = createMockNodeListHandler(); const result = handleRunNode({ nodes: [mockRunNode], nodeListHandler: mockNodeListHandler, - parentStyleId: 'ParagraphStyle', + extraParams: { + paragraphProperties: { + styleId: 'ParagraphStyle', + }, + }, docx: mockDocx, + translatedLinkedStyles, }); expect(result.nodes).toHaveLength(1); @@ -154,15 +234,16 @@ describe('runImporter', () => { it('should combine paragraph and run styles with correct precedence', () => { // Create styles with paragraph and run styles - const paragraphStyle = createMockStyle('ParagraphStyle', [ - createMockFont('Times New Roman'), - createMockSize('24'), // 12pt - createMockBold(), - ]); + const paragraphStyle = createMockStyle( + 'ParagraphStyle', + [createMockFont('Times New Roman'), createMockSize('24'), createMockBold()], + 'paragraph', + ); - const runStyle = createMockStyle('RunStyle', [createMockFont('Arial'), createMockItalic()]); + const runStyle = createMockStyle('RunStyle', [createMockFont('Arial'), createMockItalic()], 'character'); const mockDocx = createMockDocx([paragraphStyle, runStyle]); + const translatedLinkedStyles = buildTranslatedLinkedStyles([paragraphStyle, runStyle]); const mockRunNode = createMockRunNode([createMockRunStyle('RunStyle')]); const mockNodeListHandler = createMockNodeListHandler(); @@ -175,6 +256,7 @@ describe('runImporter', () => { }, }, docx: mockDocx, + translatedLinkedStyles, }); expect(result.nodes).toHaveLength(1); @@ -198,12 +280,14 @@ describe('runImporter', () => { it('should handle run nodes without run styles', () => { // Create style with only paragraph styles - const paragraphStyle = createMockStyle('ParagraphStyle', [ - createMockFont('Times New Roman'), - createMockSize('24'), // 12pt - ]); + const paragraphStyle = createMockStyle( + 'ParagraphStyle', + [createMockFont('Times New Roman'), createMockSize('24')], + 'paragraph', + ); const mockDocx = createMockDocx([paragraphStyle]); + const translatedLinkedStyles = buildTranslatedLinkedStyles([paragraphStyle]); const mockRunNode = createMockRunNode([createMockBold()]); const mockNodeListHandler = createMockNodeListHandler(); @@ -216,6 +300,7 @@ describe('runImporter', () => { }, }, docx: mockDocx, + translatedLinkedStyles, }); expect(result.nodes).toHaveLength(1); @@ -239,9 +324,10 @@ describe('runImporter', () => { describe('textStyle mark stores the styleId', () => { it('should store run style ID in textStyle mark', () => { - const runStyle = createMockStyle('CustomRunStyle', [createMockFont('Calibri')]); + const runStyle = createMockStyle('CustomRunStyle', [createMockFont('Calibri')], 'character'); const mockDocx = createMockDocx([runStyle]); + const translatedLinkedStyles = buildTranslatedLinkedStyles([runStyle]); const mockRunNode = createMockRunNode([createMockRunStyle('CustomRunStyle')]); const mockNodeListHandler = createMockNodeListHandler('text', 'Styled text'); @@ -250,6 +336,7 @@ describe('runImporter', () => { nodeListHandler: mockNodeListHandler, parentStyleId: null, docx: mockDocx, + translatedLinkedStyles, }); expect(result.nodes).toHaveLength(1); @@ -274,6 +361,7 @@ describe('runImporter', () => { nodeListHandler: mockNodeListHandler, parentStyleId: null, docx: mockDocx, + translatedLinkedStyles: buildTranslatedLinkedStyles([]), }); expect(result.nodes).toHaveLength(1); @@ -289,12 +377,10 @@ describe('runImporter', () => { }); it('should handle multiple textStyle marks correctly', () => { - const runStyle = createMockStyle('MultiStyle', [ - createMockFont('Verdana'), - createMockSize('40'), // 20pt - ]); + const runStyle = createMockStyle('MultiStyle', [createMockFont('Verdana'), createMockSize('40')], 'character'); const mockDocx = createMockDocx([runStyle]); + const translatedLinkedStyles = buildTranslatedLinkedStyles([runStyle]); const mockRunNode = createMockRunNode([createMockRunStyle('MultiStyle'), createMockColor('FF0000')]); const mockNodeListHandler = createMockNodeListHandler('text', 'Multi-styled text'); @@ -303,6 +389,7 @@ describe('runImporter', () => { nodeListHandler: mockNodeListHandler, parentStyleId: null, docx: mockDocx, + translatedLinkedStyles, }); expect(result.nodes).toHaveLength(1); @@ -323,15 +410,16 @@ describe('runImporter', () => { describe('integration with real document structure', () => { it('should handle run nodes with complex style hierarchies', () => { // Create a more complex document structure - const headingStyle = createMockStyle('Heading1', [ - createMockFont('Georgia'), - createMockSize('48'), // 24pt - createMockBold(), - ]); + const headingStyle = createMockStyle( + 'Heading1', + [createMockFont('Georgia'), createMockSize('48'), createMockBold()], + 'paragraph', + ); - const emphasisStyle = createMockStyle('Emphasis', [createMockItalic(), createMockColor('0000FF')]); + const emphasisStyle = createMockStyle('Emphasis', [createMockItalic(), createMockColor('0000FF')], 'character'); const mockDocx = createMockDocx([headingStyle, emphasisStyle]); + const translatedLinkedStyles = buildTranslatedLinkedStyles([headingStyle, emphasisStyle]); const mockRunNode = createMockRunNode([createMockRunStyle('Emphasis')], 'emphasized text'); const mockNodeListHandler = createMockNodeListHandler('text', 'emphasized text'); @@ -344,6 +432,7 @@ describe('runImporter', () => { }, }, docx: mockDocx, + translatedLinkedStyles, }); expect(result.nodes).toHaveLength(1); diff --git a/packages/super-editor/src/tests/parity/adapter-parity.test.js b/packages/super-editor/src/tests/parity/adapter-parity.test.js index de07e72a8a..3504905a1f 100644 --- a/packages/super-editor/src/tests/parity/adapter-parity.test.js +++ b/packages/super-editor/src/tests/parity/adapter-parity.test.js @@ -6,11 +6,7 @@ import { computeParagraphReferenceSnapshot } from '@tests/helpers/paragraphRefer import { zipFolderToBuffer } from '@tests/helpers/zipFolderToBuffer.js'; import { Editor } from '@core/Editor.js'; import { computeParagraphAttrs } from '@superdoc/pm-adapter/attributes/paragraph.js'; -import { - buildStyleContextFromEditor, - buildConverterContextFromEditor, - createListCounterContext, -} from '../helpers/adapterTestHelpers.js'; +import { buildConverterContextFromEditor } from '../helpers/adapterTestHelpers.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -54,46 +50,45 @@ describe('adapter parity (computeParagraphAttrs)', () => { const reference = computeParagraphReferenceSnapshot(editor, match.node, match.pos); // Compute attrs via layout-engine adapter - const styleContext = buildStyleContextFromEditor(editor); const converterContext = buildConverterContextFromEditor(editor); - const adapterAttrs = computeParagraphAttrs(match.node, styleContext, undefined, converterContext); + const { paragraphAttrs } = computeParagraphAttrs(match.node, converterContext); // Compare spacing: adapter should produce px numbers when reference defines spacing const refSpacing = reference.paragraphProperties.spacing; if (refSpacing) { - expect(adapterAttrs?.spacing).toBeDefined(); + expect(paragraphAttrs?.spacing).toBeDefined(); if (refSpacing.before != null) { - expect(typeof adapterAttrs?.spacing?.before).toBe('number'); - expect(adapterAttrs.spacing.before).toBeGreaterThanOrEqual(0); + expect(typeof paragraphAttrs?.spacing?.before).toBe('number'); + expect(paragraphAttrs.spacing.before).toBeGreaterThanOrEqual(0); } if (refSpacing.after != null) { - expect(typeof adapterAttrs?.spacing?.after).toBe('number'); - expect(adapterAttrs.spacing.after).toBeGreaterThanOrEqual(0); + expect(typeof paragraphAttrs?.spacing?.after).toBe('number'); + expect(paragraphAttrs.spacing.after).toBeGreaterThanOrEqual(0); } } // Compare indent: ensure adapter returns object with matching keys const refIndent = reference.paragraphProperties.indent; if (refIndent) { - expect(adapterAttrs?.indent).toBeDefined(); + expect(paragraphAttrs?.indent).toBeDefined(); if (refIndent.left != null) { - expect(adapterAttrs.indent?.left).toBeDefined(); + expect(paragraphAttrs.indent?.left).toBeDefined(); } if (refIndent.right != null) { - expect(adapterAttrs.indent?.right).toBeDefined(); + expect(paragraphAttrs.indent?.right).toBeDefined(); } if (refIndent.firstLine != null) { - expect(adapterAttrs.indent?.firstLine ?? adapterAttrs.indent?.hanging).toBeDefined(); + expect(paragraphAttrs.indent?.firstLine ?? paragraphAttrs.indent?.hanging).toBeDefined(); } if (refIndent.hanging != null) { - expect(adapterAttrs.indent?.hanging ?? adapterAttrs.indent?.firstLine).toBeDefined(); + expect(paragraphAttrs.indent?.hanging ?? paragraphAttrs.indent?.firstLine).toBeDefined(); } } // Compare alignment (justification) if (reference.paragraphProperties.justification) { const referenceAlign = reference.paragraphProperties.justification; - expect(adapterAttrs?.alignment).toBe(referenceAlign); + expect(paragraphAttrs?.alignment).toBe(referenceAlign); } editor.destroy(); @@ -115,26 +110,24 @@ describe('adapter parity (computeParagraphAttrs)', () => { expect(reference.list).not.toBeNull(); // Compute attrs via adapter - const styleContext = buildStyleContextFromEditor(editor); const converterContext = buildConverterContextFromEditor(editor); - const listCounterContext = createListCounterContext(); - const adapterAttrs = computeParagraphAttrs(match.node, styleContext, listCounterContext, converterContext); + const { paragraphAttrs } = computeParagraphAttrs(match.node, converterContext); // Verify numberingProperties are present and correct - expect(adapterAttrs?.numberingProperties).toBeDefined(); - expect(adapterAttrs?.numberingProperties.ilvl).toEqual(reference.paragraphProperties.numberingProperties.ilvl); - expect(adapterAttrs?.numberingProperties.numId).toEqual(reference.paragraphProperties.numberingProperties.numId); + expect(paragraphAttrs?.numberingProperties).toBeDefined(); + expect(paragraphAttrs?.numberingProperties.ilvl).toEqual(reference.paragraphProperties.numberingProperties.ilvl); + expect(paragraphAttrs?.numberingProperties.numId).toEqual(reference.paragraphProperties.numberingProperties.numId); // Verify wordLayout is computed and matches reference - expect(adapterAttrs?.wordLayout).toBeDefined(); + expect(paragraphAttrs?.wordLayout).toBeDefined(); if (reference.list.markerText) { - expect(adapterAttrs?.wordLayout?.marker?.markerText).toBe(reference.list.markerText); + expect(paragraphAttrs?.wordLayout?.marker?.markerText).toBe(reference.list.markerText); } if (reference.list.justification) { - expect(adapterAttrs?.wordLayout?.marker?.justification).toBe(reference.list.justification); + expect(paragraphAttrs?.wordLayout?.marker?.justification).toBe(reference.list.justification); } if (reference.list.suffix) { - expect(adapterAttrs?.wordLayout?.marker?.suffix).toBe(reference.list.suffix); + expect(paragraphAttrs?.wordLayout?.marker?.suffix).toBe(reference.list.suffix); } editor.destroy(); @@ -164,23 +157,37 @@ describe('adapter parity (computeParagraphAttrs)', () => { expect(paraNode).toBeTruthy(); // Compute adapter attrs - const styleContext = buildStyleContextFromEditor(editor); const converterContext = buildConverterContextFromEditor(editor); - const adapterAttrs = computeParagraphAttrs(paraNode, styleContext, undefined, converterContext); + const { paragraphAttrs } = computeParagraphAttrs(paraNode, converterContext); // Verify spacing precedence if (referenceMatch.paragraphProperties.spacing) { - expect(adapterAttrs?.spacing).toBeDefined(); + expect(paragraphAttrs?.spacing).toBeDefined(); // Check for contextualSpacing if present if (referenceMatch.paragraphProperties.spacing.contextualSpacing != null) { - expect(adapterAttrs?.contextualSpacing).toBe(referenceMatch.paragraphProperties.spacing.contextualSpacing); + expect(paragraphAttrs?.contextualSpacing).toBe(referenceMatch.paragraphProperties.spacing.contextualSpacing); } } // Verify indent precedence if (referenceMatch.paragraphProperties.indent) { - expect(adapterAttrs?.indent).toEqual(referenceMatch.paragraphProperties.indent); + expect(paragraphAttrs?.indent).toBeDefined(); + const { indent } = referenceMatch.paragraphProperties; + if (indent.left != null) { + expect(paragraphAttrs.indent?.left).toBeDefined(); + expect(paragraphAttrs.indent?.left).toBeGreaterThanOrEqual(0); + } + if (indent.right != null) { + expect(paragraphAttrs.indent?.right).toBeDefined(); + expect(paragraphAttrs.indent?.right).toBeGreaterThanOrEqual(0); + } + if (indent.firstLine != null) { + expect(paragraphAttrs.indent?.firstLine ?? paragraphAttrs.indent?.hanging).toBeDefined(); + } + if (indent.hanging != null) { + expect(paragraphAttrs.indent?.hanging ?? paragraphAttrs.indent?.firstLine).toBeDefined(); + } } editor.destroy(); @@ -210,15 +217,14 @@ describe('adapter parity (computeParagraphAttrs)', () => { expect(reference.paragraphProperties.tabStops).toBeTruthy(); // Compute adapter attrs - const styleContext = buildStyleContextFromEditor(editor); const converterContext = buildConverterContextFromEditor(editor); - const adapterAttrs = computeParagraphAttrs(match.node, styleContext, undefined, converterContext); + const { paragraphAttrs } = computeParagraphAttrs(match.node, converterContext); // Verify tabs are present and correct shape/values - expect(adapterAttrs?.tabs).toBeDefined(); - expect(Array.isArray(adapterAttrs.tabs)).toBe(true); + expect(paragraphAttrs?.tabs).toBeDefined(); + expect(Array.isArray(paragraphAttrs.tabs)).toBe(true); const refTab = reference.paragraphProperties.tabStops[0]; - const adapterTab = adapterAttrs.tabs?.[0]; + const adapterTab = paragraphAttrs.tabs?.[0]; if (refTab.pos != null) { expect(adapterTab?.pos).toBe(refTab.pos); } else { @@ -234,7 +240,7 @@ describe('adapter parity (computeParagraphAttrs)', () => { } // Verify tabIntervalTwips default is set - expect(adapterAttrs?.tabIntervalTwips).toBe(styleContext.defaults.defaultTabIntervalTwips); + expect(paragraphAttrs?.tabIntervalTwips).toBe(720); editor.destroy(); }); @@ -250,79 +256,50 @@ describe('adapter parity (computeParagraphAttrs)', () => { const match = findParagraphAt(editor.state.doc, () => true); expect(match).toBeTruthy(); - const styleContext = buildStyleContextFromEditor(editor); const converterContext = buildConverterContextFromEditor(editor); - const adapterAttrs = computeParagraphAttrs(match.node, styleContext, undefined, converterContext); + const { paragraphAttrs } = computeParagraphAttrs(match.node, converterContext); // Tab interval should be set from styleContext defaults - expect(adapterAttrs?.tabIntervalTwips).toBe(styleContext.defaults.defaultTabIntervalTwips); + expect(paragraphAttrs?.tabIntervalTwips).toBe(720); editor.destroy(); }); it('returns minimal attrs for empty paragraph (defaults only)', () => { const emptyPara = { type: { name: 'paragraph' }, attrs: {} }; - const styleContext = { styles: {}, defaults: { defaultTabIntervalTwips: 720, decimalSeparator: '.' } }; - const adapterAttrs = computeParagraphAttrs(emptyPara, styleContext); + const { paragraphAttrs } = computeParagraphAttrs(emptyPara); // Even empty paragraphs get default alignment and tab interval from styleContext.defaults - expect(adapterAttrs).toBeDefined(); - expect(adapterAttrs?.tabIntervalTwips).toBe(720); - }); - - it('handles bidi + adjustRightInd by forcing right alignment and indent', () => { - const para = { - type: { name: 'paragraph' }, - attrs: { bidi: true, adjustRightInd: true }, - }; - const styleContext = { styles: {}, defaults: { defaultTabIntervalTwips: 720, decimalSeparator: '.' } }; - const adapterAttrs = computeParagraphAttrs(para, styleContext); - expect(adapterAttrs?.alignment).toBe('right'); - expect(adapterAttrs?.indent?.left).toBeGreaterThan(0); - expect(adapterAttrs?.indent?.right).toBeGreaterThan(0); + expect(paragraphAttrs).toBeDefined(); + expect(paragraphAttrs?.tabIntervalTwips).toBe(720); }); it('extracts framePr flags correctly', () => { - // Create a mock paragraph with framePr const paraWithFramePr = { type: { name: 'paragraph' }, attrs: { - framePr: { xAlign: 'right' }, + paragraphProperties: { + framePr: { xAlign: 'right' }, + }, }, }; - const styleContext = { - styles: {}, - defaults: { defaultTabIntervalTwips: 720, decimalSeparator: '.' }, - }; + const { paragraphAttrs } = computeParagraphAttrs(paraWithFramePr); - const adapterAttrs = computeParagraphAttrs(paraWithFramePr, styleContext); - - expect(adapterAttrs?.floatAlignment).toBe('right'); + expect(paragraphAttrs?.floatAlignment).toBe('right'); }); - it('extracts framePr from paragraphProperties elements', () => { - // Create a mock paragraph with framePr in paragraphProperties + it('extracts framePr from paragraphProperties', () => { const paraWithFramePr = { type: { name: 'paragraph' }, attrs: { paragraphProperties: { - elements: [ - { - name: 'w:framePr', - attributes: { 'w:xAlign': 'center' }, - }, - ], + framePr: { xAlign: 'center' }, }, }, }; - const styleContext = { - styles: {}, - defaults: { defaultTabIntervalTwips: 720, decimalSeparator: '.' }, - }; - - const adapterAttrs = computeParagraphAttrs(paraWithFramePr, styleContext); + const { paragraphAttrs } = computeParagraphAttrs(paraWithFramePr); - expect(adapterAttrs?.floatAlignment).toBe('center'); + expect(paragraphAttrs?.floatAlignment).toBe('center'); }); }); diff --git a/packages/super-editor/src/tests/parity/marker-styling.test.js b/packages/super-editor/src/tests/parity/marker-styling.test.js index 4d620d037f..dd7027945e 100644 --- a/packages/super-editor/src/tests/parity/marker-styling.test.js +++ b/packages/super-editor/src/tests/parity/marker-styling.test.js @@ -2,11 +2,7 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; import { computeParagraphReferenceSnapshot } from '@tests/helpers/paragraphReference.js'; import { computeParagraphAttrs } from '@superdoc/pm-adapter/attributes/paragraph.js'; -import { - buildStyleContextFromEditor, - buildConverterContextFromEditor, - createListCounterContext, -} from '../helpers/adapterTestHelpers.js'; +import { buildConverterContextFromEditor } from '../helpers/adapterTestHelpers.js'; const findParagraphAt = (doc, predicate) => { let match = null; @@ -44,17 +40,15 @@ describe('marker styling parity', () => { expect(reference.list).not.toBeNull(); // Get adapter attrs with wordLayout - const styleContext = buildStyleContextFromEditor(editor); const converterContext = buildConverterContextFromEditor(editor); - const listCounterContext = createListCounterContext(); - const adapterAttrs = computeParagraphAttrs(match.node, styleContext, listCounterContext, converterContext); + const { paragraphAttrs } = computeParagraphAttrs(match.node, converterContext); - expect(adapterAttrs?.wordLayout).toBeDefined(); - expect(adapterAttrs?.wordLayout?.marker).toBeDefined(); + expect(paragraphAttrs?.wordLayout).toBeDefined(); + expect(paragraphAttrs?.wordLayout?.marker).toBeDefined(); // Compare marker text if (reference.list.markerText) { - expect(adapterAttrs.wordLayout.marker.markerText).toBe(reference.list.markerText); + expect(paragraphAttrs.wordLayout.marker.markerText).toBe(reference.list.markerText); } editor.destroy(); @@ -72,14 +66,12 @@ describe('marker styling parity', () => { expect(match).toBeTruthy(); const reference = computeParagraphReferenceSnapshot(editor, match.node, match.pos); - const styleContext = buildStyleContextFromEditor(editor); const converterContext = buildConverterContextFromEditor(editor); - const listCounterContext = createListCounterContext(); - const adapterAttrs = computeParagraphAttrs(match.node, styleContext, listCounterContext, converterContext); + const { paragraphAttrs } = computeParagraphAttrs(match.node, converterContext); // Compare justification if (reference.list?.justification) { - expect(adapterAttrs?.wordLayout?.marker?.justification).toBe(reference.list.justification); + expect(paragraphAttrs?.wordLayout?.marker?.justification).toBe(reference.list.justification); } editor.destroy(); @@ -97,14 +89,12 @@ describe('marker styling parity', () => { expect(match).toBeTruthy(); const reference = computeParagraphReferenceSnapshot(editor, match.node, match.pos); - const styleContext = buildStyleContextFromEditor(editor); const converterContext = buildConverterContextFromEditor(editor); - const listCounterContext = createListCounterContext(); - const adapterAttrs = computeParagraphAttrs(match.node, styleContext, listCounterContext, converterContext); + const { paragraphAttrs } = computeParagraphAttrs(match.node, converterContext); // Compare suffix if (reference.list?.suffix) { - expect(adapterAttrs?.wordLayout?.marker?.suffix).toBe(reference.list.suffix); + expect(paragraphAttrs?.wordLayout?.marker?.suffix).toBe(reference.list.suffix); } editor.destroy(); @@ -122,14 +112,12 @@ describe('marker styling parity', () => { expect(match).toBeTruthy(); const reference = computeParagraphReferenceSnapshot(editor, match.node, match.pos); - const styleContext = buildStyleContextFromEditor(editor); const converterContext = buildConverterContextFromEditor(editor); - const listCounterContext = createListCounterContext(); - const adapterAttrs = computeParagraphAttrs(match.node, styleContext, listCounterContext, converterContext); + const { paragraphAttrs } = computeParagraphAttrs(match.node, converterContext); // Compare run properties const referenceRun = reference.list?.markerRunProps; - const wordLayoutRun = adapterAttrs?.wordLayout?.marker?.run; + const wordLayoutRun = paragraphAttrs?.wordLayout?.marker?.run; if (referenceRun && wordLayoutRun) { if (referenceRun.color) { @@ -167,15 +155,13 @@ describe('marker styling parity', () => { expect(match).toBeTruthy(); const reference = computeParagraphReferenceSnapshot(editor, match.node, match.pos); - const styleContext = buildStyleContextFromEditor(editor); const converterContext = buildConverterContextFromEditor(editor); - const listCounterContext = createListCounterContext(); - const adapterAttrs = computeParagraphAttrs(match.node, styleContext, listCounterContext, converterContext); + const { paragraphAttrs } = computeParagraphAttrs(match.node, converterContext); // The reference has markerCss from encodeCSSFromRPr // The wordLayout.marker.run should have equivalent styling properties const referenceCss = reference.list?.markerCss; - const wordLayoutRun = adapterAttrs?.wordLayout?.marker?.run; + const wordLayoutRun = paragraphAttrs?.wordLayout?.marker?.run; if (referenceCss && wordLayoutRun) { if (referenceCss.fontFamily) { @@ -197,19 +183,33 @@ describe('marker styling parity', () => { type: { name: 'paragraph' }, attrs: { listRendering: { markerText: '1.', justification: 'right', suffix: 'tab' }, - paragraphProperties: {}, + paragraphProperties: { + numberingProperties: { numId: 1, ilvl: 0 }, + }, }, }; - const styleContext = { - styles: {}, - defaults: { defaultTabIntervalTwips: 720, decimalSeparator: '.' }, + const converterContext = { + translatedNumbering: {}, + translatedLinkedStyles: { + docDefaults: { runProperties: {}, paragraphProperties: {} }, + latentStyles: {}, + styles: { + Normal: { + styleId: 'Normal', + type: 'paragraph', + default: true, + name: 'Normal', + runProperties: {}, + paragraphProperties: {}, + }, + }, + }, }; - const adapterAttrs = computeParagraphAttrs(mockListPara, styleContext, createListCounterContext()); + const { paragraphAttrs } = computeParagraphAttrs(mockListPara, converterContext); - // When listRendering is present, wordLayout should be computed even without numberingProperties - expect(adapterAttrs?.wordLayout).toBeDefined(); - expect(adapterAttrs?.wordLayout?.marker?.markerText).toBe('1.'); + expect(paragraphAttrs?.wordLayout).toBeDefined(); + expect(paragraphAttrs?.wordLayout?.marker?.markerText).toBe('1.'); }); }); diff --git a/packages/super-editor/src/tests/parity/spacing-rendering.test.js b/packages/super-editor/src/tests/parity/spacing-rendering.test.js index cf61c6d398..d9c43fa3fb 100644 --- a/packages/super-editor/src/tests/parity/spacing-rendering.test.js +++ b/packages/super-editor/src/tests/parity/spacing-rendering.test.js @@ -2,7 +2,7 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; import { computeParagraphReferenceSnapshot } from '@tests/helpers/paragraphReference.js'; import { computeParagraphAttrs } from '@superdoc/pm-adapter/attributes/paragraph.js'; -import { buildStyleContextFromEditor, buildConverterContextFromEditor } from '../helpers/adapterTestHelpers.js'; +import { buildConverterContextFromEditor } from '../helpers/adapterTestHelpers.js'; const findParagraphAt = (doc, predicate) => { let match = null; @@ -46,20 +46,19 @@ describe('spacing/indent and rendering polish', () => { expect(referenceMatch).toBeTruthy(); expect(paraNode).toBeTruthy(); - const styleContext = buildStyleContextFromEditor(editor); const converterContext = buildConverterContextFromEditor(editor); - const adapterAttrs = computeParagraphAttrs(paraNode, styleContext, undefined, converterContext); + const { paragraphAttrs } = computeParagraphAttrs(paraNode, converterContext); // Compare spacing.before if (referenceMatch.paragraphProperties.spacing?.before !== undefined) { - expect(typeof adapterAttrs?.spacing?.before).toBe('number'); - expect(adapterAttrs.spacing.before).toBeGreaterThanOrEqual(0); + expect(typeof paragraphAttrs?.spacing?.before).toBe('number'); + expect(paragraphAttrs.spacing.before).toBeGreaterThanOrEqual(0); } // Compare spacing.after if (referenceMatch.paragraphProperties.spacing?.after !== undefined) { - expect(typeof adapterAttrs?.spacing?.after).toBe('number'); - expect(adapterAttrs.spacing.after).toBeGreaterThanOrEqual(0); + expect(typeof paragraphAttrs?.spacing?.after).toBe('number'); + expect(paragraphAttrs.spacing.after).toBeGreaterThanOrEqual(0); } editor.destroy(); @@ -92,14 +91,13 @@ describe('spacing/indent and rendering polish', () => { return; } - const styleContext = buildStyleContextFromEditor(editor); const converterContext = buildConverterContextFromEditor(editor); - const adapterAttrs = computeParagraphAttrs(paraNode, styleContext, undefined, converterContext); + const { paragraphAttrs } = computeParagraphAttrs(paraNode, converterContext); // Compare line spacing if (referenceMatch.paragraphProperties.spacing?.line !== undefined) { - expect(typeof adapterAttrs?.spacing?.line).toBe('number'); - expect(adapterAttrs.spacing.line).toBeGreaterThan(0); + expect(typeof paragraphAttrs?.spacing?.line).toBe('number'); + expect(paragraphAttrs.spacing.line).toBeGreaterThan(0); } editor.destroy(); @@ -130,30 +128,24 @@ describe('spacing/indent and rendering polish', () => { const mockPara = { type: { name: 'paragraph' }, attrs: { - spacing: { + paragraphProperties: { contextualSpacing: true, }, }, }; - const styleContext = { - styles: {}, - defaults: { defaultTabIntervalTwips: 720, decimalSeparator: '.' }, - }; - - const adapterAttrs = computeParagraphAttrs(mockPara, styleContext); - expect(adapterAttrs?.contextualSpacing).toBe(true); + const { paragraphAttrs } = computeParagraphAttrs(mockPara); + expect(paragraphAttrs?.contextualSpacing).toBe(true); editor.destroy(); return; } - const styleContext = buildStyleContextFromEditor(editor); const converterContext = buildConverterContextFromEditor(editor); - const adapterAttrs = computeParagraphAttrs(match, styleContext, undefined, converterContext); + const { paragraphAttrs } = computeParagraphAttrs(match, converterContext); // contextualSpacing should be preserved if (match.attrs?.paragraphProperties?.contextualSpacing !== undefined) { - expect(adapterAttrs?.contextualSpacing).toBe(match.attrs.paragraphProperties.contextualSpacing); + expect(paragraphAttrs?.contextualSpacing).toBe(match.attrs.paragraphProperties.contextualSpacing); } editor.destroy(); @@ -164,25 +156,22 @@ describe('spacing/indent and rendering polish', () => { const mockPara = { type: { name: 'paragraph' }, attrs: { - spacing: { - beforeAutospacing: true, - afterAutospacing: false, + paragraphProperties: { + spacing: { + beforeAutospacing: true, + afterAutospacing: false, + }, }, }, }; - const styleContext = { - styles: {}, - defaults: { defaultTabIntervalTwips: 720, decimalSeparator: '.' }, - }; - - const adapterAttrs = computeParagraphAttrs(mockPara, styleContext); + const { paragraphAttrs } = computeParagraphAttrs(mockPara); // Autospacing flags should be preserved in spacing object - expect(typeof adapterAttrs?.spacing?.beforeAutospacing).toBe('boolean'); - expect(typeof adapterAttrs?.spacing?.afterAutospacing).toBe('boolean'); - expect(adapterAttrs.spacing.beforeAutospacing).toBe(true); - expect(adapterAttrs.spacing.afterAutospacing).toBe(false); + expect(typeof paragraphAttrs?.spacing?.beforeAutospacing).toBe('boolean'); + expect(typeof paragraphAttrs?.spacing?.afterAutospacing).toBe('boolean'); + expect(paragraphAttrs.spacing.beforeAutospacing).toBe(true); + expect(paragraphAttrs.spacing.afterAutospacing).toBe(false); }); it('compares text indent and padding between reference and adapter', () => { @@ -211,24 +200,23 @@ describe('spacing/indent and rendering polish', () => { return; } - const styleContext = buildStyleContextFromEditor(editor); const converterContext = buildConverterContextFromEditor(editor); - const adapterAttrs = computeParagraphAttrs(paraNode, styleContext, undefined, converterContext); + const { paragraphAttrs } = computeParagraphAttrs(paraNode, converterContext); const referenceIndent = referenceMatch.paragraphProperties.indent; // Compare indent numeric properties if (referenceIndent.left !== undefined) { - expect(typeof adapterAttrs.indent.left).toBe('number'); + expect(typeof paragraphAttrs.indent.left).toBe('number'); } if (referenceIndent.right !== undefined) { - expect(typeof adapterAttrs.indent.right).toBe('number'); + expect(typeof paragraphAttrs.indent.right).toBe('number'); } if (referenceIndent.firstLine !== undefined) { - expect(typeof adapterAttrs.indent.firstLine).toBe('number'); + expect(typeof paragraphAttrs.indent.firstLine).toBe('number'); } if (referenceIndent.hanging !== undefined) { - expect(typeof adapterAttrs.indent.hanging).toBe('number'); + expect(typeof paragraphAttrs.indent.hanging).toBe('number'); } editor.destroy(); @@ -245,13 +233,8 @@ describe('spacing/indent and rendering polish', () => { }, }; - const styleContext = { - styles: {}, - defaults: { defaultTabIntervalTwips: 720, decimalSeparator: '.' }, - }; - - const adapterAttrs = computeParagraphAttrs(mockPara, styleContext); - expect(adapterAttrs?.keepNext).toBe(true); + const { paragraphAttrs } = computeParagraphAttrs(mockPara); + expect(paragraphAttrs?.keepNext).toBe(true); }); it('ensures keepLines flag is preserved', () => { @@ -265,13 +248,8 @@ describe('spacing/indent and rendering polish', () => { }, }; - const styleContext = { - styles: {}, - defaults: { defaultTabIntervalTwips: 720, decimalSeparator: '.' }, - }; - - const adapterAttrs = computeParagraphAttrs(mockPara, styleContext); - expect(adapterAttrs?.keepLines).toBe(true); + const { paragraphAttrs } = computeParagraphAttrs(mockPara); + expect(paragraphAttrs?.keepLines).toBe(true); }); it('ensures paragraph borders are preserved', () => { @@ -280,20 +258,17 @@ describe('spacing/indent and rendering polish', () => { const mockPara = { type: { name: 'paragraph' }, attrs: { - borders: { - top: { val: 'single', size: 32, color: 'FF0000' }, - bottom: { val: 'single', size: 32, color: '0000FF' }, + paragraphProperties: { + borders: { + top: { val: 'single', size: 32, color: 'FF0000' }, + bottom: { val: 'single', size: 32, color: '0000FF' }, + }, }, }, }; - const styleContext = { - styles: {}, - defaults: { defaultTabIntervalTwips: 720, decimalSeparator: '.' }, - }; - - const adapterAttrs = computeParagraphAttrs(mockPara, styleContext); - expect(adapterAttrs?.borders).toEqual({ + const { paragraphAttrs } = computeParagraphAttrs(mockPara); + expect(paragraphAttrs?.borders).toEqual({ top: { style: 'solid', width: (32 / 8) * (96 / 72), color: '#FF0000' }, bottom: { style: 'solid', width: (32 / 8) * (96 / 72), color: '#0000FF' }, }); @@ -303,38 +278,32 @@ describe('spacing/indent and rendering polish', () => { const mockPara = { type: { name: 'paragraph' }, attrs: { - shading: { - fill: '#FFFF00', - color: '#000000', + paragraphProperties: { + shading: { + fill: '#FFFF00', + color: '#000000', + }, }, }, }; - const styleContext = { - styles: {}, - defaults: { defaultTabIntervalTwips: 720, decimalSeparator: '.' }, - }; - - const adapterAttrs = computeParagraphAttrs(mockPara, styleContext); - expect(adapterAttrs?.shading).toEqual(mockPara.attrs.shading); + const { paragraphAttrs } = computeParagraphAttrs(mockPara); + expect(paragraphAttrs?.shading).toEqual(mockPara.attrs.paragraphProperties.shading); }); it('ensures framePr and floatAlignment flags are preserved', () => { const mockPara = { type: { name: 'paragraph' }, attrs: { - framePr: { - xAlign: 'right', + paragraphProperties: { + framePr: { + xAlign: 'right', + }, }, }, }; - const styleContext = { - styles: {}, - defaults: { defaultTabIntervalTwips: 720, decimalSeparator: '.' }, - }; - - const adapterAttrs = computeParagraphAttrs(mockPara, styleContext); - expect(adapterAttrs?.floatAlignment).toBe('right'); + const { paragraphAttrs } = computeParagraphAttrs(mockPara); + expect(paragraphAttrs?.floatAlignment).toBe('right'); }); }); diff --git a/packages/super-editor/src/tests/parity/tabs-hanging.test.js b/packages/super-editor/src/tests/parity/tabs-hanging.test.js index 29ab58c374..dbc6d15b54 100644 --- a/packages/super-editor/src/tests/parity/tabs-hanging.test.js +++ b/packages/super-editor/src/tests/parity/tabs-hanging.test.js @@ -6,7 +6,7 @@ import { computeParagraphReferenceSnapshot } from '@tests/helpers/paragraphRefer import { zipFolderToBuffer } from '@tests/helpers/zipFolderToBuffer.js'; import { Editor } from '@core/Editor.js'; import { computeParagraphAttrs } from '@superdoc/pm-adapter/attributes/paragraph.js'; -import { buildStyleContextFromEditor, buildConverterContextFromEditor } from '../helpers/adapterTestHelpers.js'; +import { buildConverterContextFromEditor } from '../helpers/adapterTestHelpers.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -35,16 +35,15 @@ describe('tabs and hanging indent parity', () => { expect(reference.paragraphProperties.tabStops).toBeTruthy(); // Get adapter attrs - const styleContext = buildStyleContextFromEditor(editor); const converterContext = buildConverterContextFromEditor(editor); - const adapterAttrs = computeParagraphAttrs(match.node, styleContext, undefined, converterContext); + const { paragraphAttrs } = computeParagraphAttrs(match.node, converterContext); - expect(adapterAttrs?.tabs).toBeDefined(); - expect(adapterAttrs.tabs.length).toBeGreaterThan(0); + expect(paragraphAttrs?.tabs).toBeDefined(); + expect(paragraphAttrs.tabs.length).toBeGreaterThan(0); // Compare tab stop properties const referenceTab = reference.paragraphProperties.tabStops[0]; - const adapterTab = adapterAttrs.tabs[0]; + const adapterTab = paragraphAttrs.tabs[0]; if (referenceTab.pos != null) { expect(adapterTab.pos).toBe(referenceTab.pos); @@ -82,12 +81,11 @@ describe('tabs and hanging indent parity', () => { expect(match).toBeTruthy(); // Get style context which has default tab interval - const styleContext = buildStyleContextFromEditor(editor); const converterContext = buildConverterContextFromEditor(editor); - const adapterAttrs = computeParagraphAttrs(match.node, styleContext, undefined, converterContext); + const { paragraphAttrs } = computeParagraphAttrs(match.node, converterContext); // Adapter should use the same default tab interval - expect(adapterAttrs?.tabIntervalTwips).toBe(styleContext.defaults.defaultTabIntervalTwips); + expect(paragraphAttrs?.tabIntervalTwips).toBe(720); editor.destroy(); }); @@ -119,13 +117,12 @@ describe('tabs and hanging indent parity', () => { const reference = computeParagraphReferenceSnapshot(editor, match.node, match.pos); // Get adapter attrs - const styleContext = buildStyleContextFromEditor(editor); const converterContext = buildConverterContextFromEditor(editor); - const adapterAttrs = computeParagraphAttrs(match.node, styleContext, undefined, converterContext); + const { paragraphAttrs } = computeParagraphAttrs(match.node, converterContext); // Compare indent properties const referenceIndent = reference.paragraphProperties.indent; - const adapterIndent = adapterAttrs?.indent; + const adapterIndent = paragraphAttrs?.indent; if (referenceIndent) { expect(adapterIndent).toBeDefined(); @@ -162,14 +159,13 @@ describe('tabs and hanging indent parity', () => { expect(match).toBeTruthy(); - const styleContext = buildStyleContextFromEditor(editor); const converterContext = buildConverterContextFromEditor(editor); - const adapterAttrs = computeParagraphAttrs(match.node, styleContext, undefined, converterContext); + const { paragraphAttrs } = computeParagraphAttrs(match.node, converterContext); - expect(adapterAttrs?.tabs).toBeDefined(); + expect(paragraphAttrs?.tabs).toBeDefined(); // Tab positions should be in twips (positive integers) - for (const tab of adapterAttrs.tabs) { + for (const tab of paragraphAttrs.tabs) { expect(tab.pos).toBeTypeOf('number'); expect(Number.isInteger(tab.pos)).toBe(true); expect(tab.pos).toBeGreaterThan(0); @@ -197,13 +193,12 @@ describe('tabs and hanging indent parity', () => { expect(match).toBeTruthy(); const reference = computeParagraphReferenceSnapshot(editor, match.node, match.pos); - const styleContext = buildStyleContextFromEditor(editor); const converterContext = buildConverterContextFromEditor(editor); - const adapterAttrs = computeParagraphAttrs(match.node, styleContext, undefined, converterContext); + const { paragraphAttrs } = computeParagraphAttrs(match.node, converterContext); // If reference has tab leader, adapter should preserve it const referenceTab = reference.paragraphProperties.tabStops?.[0]; - const adapterTab = adapterAttrs?.tabs?.[0]; + const adapterTab = paragraphAttrs?.tabs?.[0]; if (referenceTab && adapterTab && referenceTab.leader && referenceTab.leader !== 'none') { expect(adapterTab.leader).toBe(referenceTab.leader); @@ -216,20 +211,17 @@ describe('tabs and hanging indent parity', () => { const para = { type: { name: 'paragraph' }, attrs: { - tabStops: [ - { tab: { tabType: 'start' } }, // missing pos - { pos: 'not-a-number' }, // invalid pos - ], + paragraphProperties: { + tabStops: [ + { tab: { tabType: 'start' } }, // missing pos + { pos: 'not-a-number' }, // invalid pos + ], + }, }, }; - const styleContext = { - styles: {}, - defaults: { defaultTabIntervalTwips: 720, decimalSeparator: '.' }, - }; - - const adapterAttrs = computeParagraphAttrs(para, styleContext); + const { paragraphAttrs } = computeParagraphAttrs(para); - expect(adapterAttrs?.tabs).toBeUndefined(); + expect(paragraphAttrs?.tabs).toBeUndefined(); }); }); diff --git a/packages/word-layout/src/index.ts b/packages/word-layout/src/index.ts index 767ece749f..6b666d20e0 100644 --- a/packages/word-layout/src/index.ts +++ b/packages/word-layout/src/index.ts @@ -4,18 +4,8 @@ * Track A focuses on defining data interfaces and pure helpers. */ -import type { - DocDefaults, - ParagraphIndent, - ResolveMarkerRunPropsInput, - ResolvedNumberingProperties, - ResolvedRunProperties, - WordParagraphLayoutInput, - WordParagraphLayoutOutput, - WordListMarkerLayout, - WordListSuffix, -} from './types.js'; -import { buildFontCss, DEFAULT_LIST_HANGING_PX, formatMarkerText, LIST_MARKER_GAP } from './marker-utils.js'; +import type { WordParagraphLayoutInput, WordParagraphLayoutOutput, WordListSuffix } from './types.js'; +import { DEFAULT_LIST_HANGING_PX, LIST_MARKER_GAP } from './marker-utils.js'; import { twipsToPixels } from './unit-conversions.js'; export * from './types.js'; @@ -36,7 +26,7 @@ export { pointsToHalfPoints, } from './unit-conversions.js'; -export { buildFontCss, LIST_MARKER_GAP, DEFAULT_LIST_HANGING_PX } from './marker-utils.js'; +export { LIST_MARKER_GAP, DEFAULT_LIST_HANGING_PX } from './marker-utils.js'; export type { NumberingFormat } from './marker-utils.js'; /** @@ -94,16 +84,17 @@ export type { NumberingFormat } from './marker-utils.js'; * ``` */ export function computeWordParagraphLayout(input: WordParagraphLayoutInput): WordParagraphLayoutOutput { - const { paragraph, docDefaults, measurement } = input; - const numbering = (input.numbering ?? paragraph.numberingProperties) || null; - const indent = mergeIndent(docDefaults, paragraph.indent); - const tabs = Array.isArray(paragraph.tabs) ? paragraph.tabs : []; + const { paragraph, markerRun, listRenderingAttrs } = input; - const indentLeftPx = indent.left ?? 0; - const hangingPxRaw = indent.hanging ?? (indent.firstLine != null && indent.firstLine < 0 ? -indent.firstLine : 0); - const firstLinePx = indent.firstLine; - const defaultTabIntervalPx = resolveDefaultTabIntervalPx(paragraph.tabIntervalTwips, docDefaults); - const tabsPx = tabs.map((tab) => tab.position); + const layout: WordParagraphLayoutOutput = { + indentLeftPx: paragraph.indent?.left ?? 0, + hangingPx: paragraph.indent?.hanging ?? 0, + firstLinePx: paragraph.indent?.firstLine, + tabsPx: paragraph.tabs?.map((tab) => twipsToPixels(tab.pos)) ?? [], + textStartPx: paragraph.indent?.left ?? 0, + marker: undefined, + defaultTabIntervalPx: paragraph.tabIntervalTwips, + }; // Detect "firstLine indent" pattern: OOXML allows lists to use firstLine instead of hanging. // Standard: left=720, hanging=720 (marker hangs back to position 0) @@ -111,252 +102,48 @@ export function computeWordParagraphLayout(input: WordParagraphLayoutInput): Wor // Per OOXML spec, firstLine and hanging are mutually exclusive. // Validate that firstLine is a finite number to handle NaN, Infinity, and -Infinity gracefully. const hasFirstLineIndent = - indent.firstLine != null && Number.isFinite(indent.firstLine) && indent.firstLine > 0 && !indent.hanging; - - const layout: WordParagraphLayoutOutput = { - indentLeftPx, - hangingPx: Math.max(hangingPxRaw, 0), - firstLinePx, - tabsPx, - textStartPx: indentLeftPx, - marker: undefined, - resolvedIndent: indent, - resolvedTabs: tabs, - defaultTabIntervalPx, - }; - - if (!numbering) { - return layout; - } - - const markerRun = - input.markerRun ?? - resolveMarkerRunProperties({ - inlineMarkerRpr: paragraph.numberingProperties?.resolvedMarkerRpr, - resolvedParagraphProps: paragraph, - numbering, - docDefaults, - cached: numbering.resolvedMarkerRpr, - }); - - const markerText = numbering.markerText ?? formatMarkerText(numbering); - const glyphWidthPx = - measurement?.measureText && markerText - ? measurement.measureText(markerText, buildFontCss(markerRun), { letterSpacing: markerRun.letterSpacing }) - : undefined; + paragraph.indent?.firstLine != null && + Number.isFinite(paragraph.indent.firstLine) && + paragraph.indent.firstLine > 0 && + !paragraph.indent.hanging; let markerBoxWidthPx: number; let markerX: number; - if (hasFirstLineIndent) { // FirstLine pattern: marker at (left + firstLine), text follows inline - markerBoxWidthPx = - glyphWidthPx != null && glyphWidthPx > 0 ? glyphWidthPx + LIST_MARKER_GAP : DEFAULT_LIST_HANGING_PX; - markerX = indentLeftPx + (firstLinePx ?? 0); + markerBoxWidthPx = DEFAULT_LIST_HANGING_PX; + markerX = layout.indentLeftPx + (layout.firstLinePx ?? 0); layout.textStartPx = markerX + markerBoxWidthPx; layout.hangingPx = 0; layout.firstLineIndentMode = true; } else { - // Standard hanging pattern: marker hangs back from left indent - markerBoxWidthPx = resolveMarkerBoxWidth(hangingPxRaw, glyphWidthPx); - markerX = indentLeftPx - markerBoxWidthPx; + if (layout.hangingPx === 0) { + markerBoxWidthPx = DEFAULT_LIST_HANGING_PX; + } else { + markerBoxWidthPx = layout.hangingPx; + } + markerX = layout.indentLeftPx - markerBoxWidthPx; layout.hangingPx = markerBoxWidthPx; } - layout.marker = buildMarkerLayout({ - numbering, - markerText, - markerRun, - glyphWidthPx, - textStartPx: layout.textStartPx, - markerBoxWidthPx, - markerX, - }); + layout.marker = { + markerText: listRenderingAttrs.markerText, + // markerBoxWidthPx: markerBoxWidthPx + 1000, + // markerX, + // textStartX: layout.textStartPx, + // Gutter is the small gap between marker and text, not the full marker box width + // gutterWidthPx: LIST_MARKER_GAP, + justification: listRenderingAttrs.justification ?? 'left', + suffix: normalizeSuffix(listRenderingAttrs.suffix), + run: markerRun, + }; return layout; } -/** - * Resolves the final run properties for a list marker by merging defaults, document defaults, - * numbering level properties, and inline overrides. - * - * This function implements a layered property resolution system where each layer can override - * properties from the previous layer. The resolution order is: - * 1. Base defaults (Times New Roman, 12pt, black) - * 2. Document defaults - * 3. Numbering level resolved properties - * 4. Inline marker properties - * - * @param input - The marker run properties input containing various property layers - * @param input.inlineMarkerRpr - Inline marker run properties that take highest precedence - * @param input.resolvedParagraphProps - The resolved paragraph properties - * @param input.numbering - The numbering properties that may contain marker styling - * @param input.docDefaults - Document-wide default run properties - * @param input.cached - Cached resolved properties to avoid recalculation - * - * @returns The fully resolved run properties for the marker, including font, size, color, and styling - * - * @example - * ```typescript - * const markerProps = resolveMarkerRunProperties({ - * inlineMarkerRpr: { fontFamily: 'Roboto', bold: true }, - * resolvedParagraphProps: { indent: {} }, - * numbering: null, - * docDefaults: { run: { fontSize: 14, color: '#333333' } } - * }); - * - * console.log(markerProps.fontFamily); // "Roboto" - * console.log(markerProps.fontSize); // 14 - * console.log(markerProps.bold); // true - * ``` - */ -export function resolveMarkerRunProperties(input: ResolveMarkerRunPropsInput): ResolvedRunProperties { - if (input.cached) { - return input.cached; - } - - const numberingResolved = - input.numbering?.resolvedMarkerRpr || input.resolvedParagraphProps.numberingProperties?.resolvedMarkerRpr; - - const result = mergeRunProperties( - DEFAULT_MARKER_RUN, - input.docDefaults.run, - numberingResolved, - input.inlineMarkerRpr, - ); - - return result; -} - -const DEFAULT_MARKER_RUN: ResolvedRunProperties = { - fontFamily: 'Times New Roman', - fontSize: 12, - color: '#000000', -}; - -const mergeRunProperties = ( - ...layers: Array | null | undefined> -): ResolvedRunProperties => { - const result: ResolvedRunProperties = { ...DEFAULT_MARKER_RUN }; - - const applyLayer = (layer?: Partial | null) => { - if (!layer) return; - for (const [key, value] of Object.entries(layer)) { - if (value == null) continue; - - // Validate key is a valid property - if (!isValidRunPropertyKey(key)) continue; - - const typedKey = key as keyof ResolvedRunProperties; - if (typeof value === 'object' && !Array.isArray(value)) { - const current = result[typedKey]; - const next = - typeof current === 'object' && current != null - ? { ...(current as Record), ...(value as Record) } - : { ...(value as Record) }; - (result as Record)[typedKey as string] = next; - } else { - (result as Record)[typedKey as string] = value; - } - } - }; - - const isValidRunPropertyKey = (key: string): key is keyof ResolvedRunProperties => { - const validKeys: Array = [ - 'fontFamily', - 'fontSize', - 'bold', - 'italic', - 'underline', - 'strike', - 'color', - 'highlight', - 'smallCaps', - 'allCaps', - 'baselineShift', - 'letterSpacing', - 'scale', - 'lang', - ]; - return validKeys.includes(key as keyof ResolvedRunProperties); - }; - - for (const layer of layers) { - applyLayer(layer); - } - - return result; -}; - -const mergeIndent = (docDefaults: DocDefaults, paragraphIndent?: ParagraphIndent | null): ParagraphIndent => { - const base = docDefaults.paragraph?.indent ?? {}; - return { - ...base, - ...(paragraphIndent ?? {}), - }; -}; - -const resolveDefaultTabIntervalPx = ( - paragraphIntervalTwips: number | undefined, - docDefaults: DocDefaults, -): number | undefined => { - if (Number.isFinite(paragraphIntervalTwips)) { - return twipsToPixels(paragraphIntervalTwips); - } - if (Number.isFinite(docDefaults.defaultTabIntervalTwips)) { - return twipsToPixels(docDefaults.defaultTabIntervalTwips); - } - return undefined; -}; - -const resolveMarkerBoxWidth = (hangingPxRaw: number, glyphWidthPx?: number): number => { - let markerBox = Math.max(hangingPxRaw || 0, 0); - if (markerBox <= 0) { - if (glyphWidthPx != null && glyphWidthPx > 0) { - markerBox = glyphWidthPx + LIST_MARKER_GAP; - } else { - markerBox = DEFAULT_LIST_HANGING_PX; - } - } else if (glyphWidthPx != null && glyphWidthPx + LIST_MARKER_GAP > markerBox) { - markerBox = glyphWidthPx + LIST_MARKER_GAP; - } - return markerBox; -}; - -const buildMarkerLayout = ({ - numbering, - markerText, - markerRun, - glyphWidthPx, - textStartPx, - markerBoxWidthPx, - markerX, -}: { - numbering: ResolvedNumberingProperties; - markerText: string; - markerRun: ResolvedRunProperties; - glyphWidthPx?: number; - textStartPx: number; - markerBoxWidthPx: number; - markerX: number; -}): WordListMarkerLayout => ({ - markerText, - glyphWidthPx, - markerBoxWidthPx, - markerX, - textStartX: textStartPx, - baselineOffsetPx: markerRun.baselineShift ?? 0, - // Gutter is the small gap between marker and text, not the full marker box width - gutterWidthPx: LIST_MARKER_GAP, - justification: numbering.lvlJc ?? 'left', - suffix: normalizeSuffix(numbering.suffix) ?? 'tab', - run: markerRun, - path: numbering.path, -}); - const normalizeSuffix = (suffix?: string | null): WordListSuffix => { if (suffix === 'tab' || suffix === 'space' || suffix === 'nothing') { return suffix; } - return undefined; + return 'tab'; }; diff --git a/packages/word-layout/src/marker-utils.ts b/packages/word-layout/src/marker-utils.ts index 694f0208d0..1f763f7c4d 100644 --- a/packages/word-layout/src/marker-utils.ts +++ b/packages/word-layout/src/marker-utils.ts @@ -1,5 +1,3 @@ -import type { ResolvedNumberingProperties, ResolvedRunProperties } from './types.js'; - /** * Union type representing all supported numbering format types. * These formats determine how list markers are displayed. @@ -23,341 +21,3 @@ export const LIST_MARKER_GAP = 8; * The bullet point (•) is the standard Unicode character for unordered lists. */ export const DEFAULT_BULLET_GLYPH = '•'; - -const DEFAULT_DECIMAL_PATTERN = '%1.'; - -// ASCII code constants for alphabetic conversion -const ASCII_UPPERCASE_A = 65; -const ASCII_LOWERCASE_A = 97; -const ALPHABET_SIZE = 26; - -const ROMAN_NUMERALS: Array<[number, string]> = [ - [1000, 'M'], - [900, 'CM'], - [500, 'D'], - [400, 'CD'], - [100, 'C'], - [90, 'XC'], - [50, 'L'], - [40, 'XL'], - [10, 'X'], - [9, 'IX'], - [5, 'V'], - [4, 'IV'], - [1, 'I'], -]; - -/** - * Converts a positive integer to alphabetic representation (Excel-style column naming). - * Uses base-26 numbering where 1='a', 26='z', 27='aa', 52='az', 53='ba', etc. - * - * @param value - The positive integer to convert (must be >= 1) - * @param uppercase - Whether to use uppercase letters (A-Z) or lowercase (a-z) - * - * @returns The alphabetic representation, or empty string if value is invalid - * - * @example - * ```typescript - * toAlpha(1, false); // Returns: "a" - * toAlpha(26, false); // Returns: "z" - * toAlpha(27, false); // Returns: "aa" - * toAlpha(52, false); // Returns: "az" - * toAlpha(702, false); // Returns: "zz" - * toAlpha(703, false); // Returns: "aaa" - * toAlpha(1, true); // Returns: "A" - * toAlpha(26, true); // Returns: "Z" - * toAlpha(27, true); // Returns: "AA" - * toAlpha(0, false); // Returns: "" (invalid) - * toAlpha(-5, false); // Returns: "" (invalid) - * toAlpha(NaN, false); // Returns: "" (invalid) - * ``` - * - * @remarks - * - Returns empty string for non-finite values (NaN, Infinity, -Infinity) - * - Returns empty string for zero or negative values - * - Decimal values are floored to the nearest integer - * - This is a bijective base-26 system: no "zero" value exists, so 'a' = 1 - */ -const toAlpha = (value: number, uppercase: boolean): string => { - if (!Number.isFinite(value) || value <= 0) { - return ''; - } - let num = Math.floor(value); - let result = ''; - while (num > 0) { - num--; - const mod = num % ALPHABET_SIZE; - result = String.fromCharCode((uppercase ? ASCII_UPPERCASE_A : ASCII_LOWERCASE_A) + mod) + result; - num = Math.floor(num / ALPHABET_SIZE); - } - return result; -}; - -/** - * Converts a positive integer to Roman numeral representation. - * Supports values from 1 to 3999 using standard Roman numeral notation with subtractive rules. - * - * @param value - The positive integer to convert (1-3999) - * @param uppercase - Whether to use uppercase (I, V, X, etc.) or lowercase (i, v, x, etc.) - * - * @returns The Roman numeral string, or empty string if value is invalid - * - * @example - * ```typescript - * toRoman(1, true); // Returns: "I" - * toRoman(4, true); // Returns: "IV" - * toRoman(9, true); // Returns: "IX" - * toRoman(40, true); // Returns: "XL" - * toRoman(90, true); // Returns: "XC" - * toRoman(400, true); // Returns: "CD" - * toRoman(900, true); // Returns: "CM" - * toRoman(1994, true); // Returns: "MCMXCIV" - * toRoman(3999, true); // Returns: "MMMCMXCIX" - * toRoman(4, false); // Returns: "iv" - * toRoman(0, true); // Returns: "" (invalid) - * toRoman(-5, true); // Returns: "" (invalid) - * toRoman(NaN, true); // Returns: "" (invalid) - * ``` - * - * @remarks - * - Returns empty string for non-finite values (NaN, Infinity, -Infinity) - * - Returns empty string for zero or negative values - * - Decimal values are floored to the nearest integer - * - Uses subtractive notation: IV (4), IX (9), XL (40), XC (90), CD (400), CM (900) - * - Maximum supported value is 3999 (MMMCMXCIX) - * - Values above 3999 will produce valid output but are non-standard - */ -const toRoman = (value: number, uppercase: boolean): string => { - if (!Number.isFinite(value) || value <= 0) { - return ''; - } - let num = Math.floor(value); - let result = ''; - for (const [romanValue, glyph] of ROMAN_NUMERALS) { - while (num >= romanValue) { - result += glyph; - num -= romanValue; - } - } - return uppercase ? result : result.toLowerCase(); -}; - -/** - * Formats a number as a decimal string representation. - * Non-finite values return empty string, and decimal values are floored to integers. - * - * @param value - The number to format - * - * @returns The decimal string representation, or empty string if value is non-finite - * - * @example - * ```typescript - * formatDecimal(1); // Returns: "1" - * formatDecimal(42); // Returns: "42" - * formatDecimal(0); // Returns: "0" - * formatDecimal(-5); // Returns: "-5" - * formatDecimal(3.7); // Returns: "3" - * formatDecimal(9.99); // Returns: "9" - * formatDecimal(NaN); // Returns: "" - * formatDecimal(Infinity); // Returns: "" - * formatDecimal(-Infinity); // Returns: "" - * ``` - * - * @remarks - * - Returns empty string for NaN, Infinity, or -Infinity - * - Decimal values are floored (truncated towards negative infinity) - * - Negative numbers are preserved (e.g., -5 becomes "-5") - */ -const formatDecimal = (value: number): string => { - if (!Number.isFinite(value)) return ''; - return String(Math.floor(value)); -}; - -/** - * Applies a numbering format to a value, converting it to the appropriate string representation. - * Supports decimal, alphabetic (upper/lower), and Roman numeral (upper/lower) formats. - * - * @param value - The number to format - * @param format - The format type to apply. If undefined or unrecognized, defaults to decimal - * - * @returns The formatted string representation - * - * @example - * ```typescript - * applyFormat(5, 'decimal'); // Returns: "5" - * applyFormat(5, 'lowerLetter'); // Returns: "e" - * applyFormat(5, 'upperLetter'); // Returns: "E" - * applyFormat(5, 'lowerRoman'); // Returns: "v" - * applyFormat(5, 'upperRoman'); // Returns: "V" - * applyFormat(5, undefined); // Returns: "5" (default to decimal) - * applyFormat(5, 'unknown'); // Returns: "5" (default to decimal) - * applyFormat(27, 'lowerLetter'); // Returns: "aa" - * applyFormat(1994, 'upperRoman'); // Returns: "MCMXCIV" - * ``` - * - * @remarks - * - Undefined or unrecognized format defaults to decimal formatting - * - See {@link toAlpha}, {@link toRoman}, and {@link formatDecimal} for format-specific behavior - * - Invalid values (NaN, Infinity, negative, zero) are handled by individual format functions - */ -const applyFormat = (value: number, format?: NumberingFormat): string => { - switch (format) { - case 'lowerLetter': - return toAlpha(value, false); - case 'upperLetter': - return toAlpha(value, true); - case 'lowerRoman': - return toRoman(value, false); - case 'upperRoman': - return toRoman(value, true); - default: - return formatDecimal(value); - } -}; - -/** - * Formats the display text for a list marker based on numbering properties. - * - * This function handles various list formats including bullets, decimals, Roman numerals, - * and alphabetic numbering. It supports multi-level numbering patterns like "1.2.3." by - * replacing placeholder patterns (%1, %2, etc.) with actual counter values. - * - * @param numbering - The numbering properties containing format, pattern, and counter path. - * If null or undefined, returns an empty string. - * @param numbering.format - The numbering format: 'bullet', 'decimal', 'lowerRoman', 'upperRoman', - * 'lowerLetter', or 'upperLetter' - * @param numbering.lvlText - The pattern template (e.g., "%1.", "%1.%2."). For bullets, this is - * the actual bullet character to display. - * @param numbering.path - Array of counter values for each level (e.g., [1, 2, 3] for "1.2.3.") - * @param numbering.start - Fallback start value if path is not provided - * - * @returns The formatted marker text ready for display (e.g., "3.", "IV)", "aa.", "•") - * - * @example - * ```typescript - * // Decimal numbering - * formatMarkerText({ - * numId: '1', - * ilvl: 0, - * format: 'decimal', - * lvlText: '%1.', - * path: [5] - * }); // Returns: "5." - * - * // Multi-level decimal - * formatMarkerText({ - * numId: '1', - * ilvl: 2, - * format: 'decimal', - * lvlText: '%1.%2.%3.', - * path: [1, 2, 3] - * }); // Returns: "1.2.3." - * - * // Roman numerals - * formatMarkerText({ - * numId: '2', - * ilvl: 0, - * format: 'upperRoman', - * lvlText: '%1)', - * path: [4] - * }); // Returns: "IV)" - * - * // Bullet - * formatMarkerText({ - * numId: '3', - * ilvl: 0, - * format: 'bullet', - * lvlText: '▪' - * }); // Returns: "▪" - * ``` - */ -export const formatMarkerText = (numbering?: ResolvedNumberingProperties | null): string => { - if (!numbering) { - return ''; - } - const path = numbering.path && numbering.path.length ? numbering.path : [numbering.start ?? 1]; - if (numbering.format === 'bullet') { - return numbering.lvlText || DEFAULT_BULLET_GLYPH; - } - const pattern = numbering.lvlText || DEFAULT_DECIMAL_PATTERN; - return pattern.replace(/%(\d+)/g, (_, lvlIndex) => { - const index = Number(lvlIndex) - 1; - const value = path[index] ?? path[path.length - 1] ?? 1; - return applyFormat(value, numbering.format); - }); -}; - -/** - * Builds a CSS font shorthand string from resolved run properties. - * - * This function constructs a valid CSS font shorthand value that can be used for - * canvas text measurement or CSS font styling. The format follows the CSS font - * specification: [style] [weight] size family. - * - * @param run - The resolved run properties containing font styling information - * @param run.fontFamily - The font family name (defaults to "Times New Roman" if falsy) - * @param run.fontSize - The font size in pixels (defaults to 12 if falsy, clamped to 1-999px) - * @param run.italic - Whether the font should be italic - * @param run.bold - Whether the font should be bold - * - * @returns A CSS font shorthand string (e.g., "italic bold 14px Arial") - * - * @example - * ```typescript - * buildFontCss({ - * fontFamily: 'Arial', - * fontSize: 14, - * bold: true, - * italic: false - * }); // Returns: "bold 14px Arial" - * - * buildFontCss({ - * fontFamily: 'Georgia', - * fontSize: 16, - * bold: true, - * italic: true - * }); // Returns: "italic bold 16px Georgia" - * - * buildFontCss({ - * fontFamily: 'Calibri', - * fontSize: 12 - * }); // Returns: "12px Calibri" - * - * buildFontCss({ - * fontFamily: 'Arial', - * fontSize: 14.7 - * }); // Returns: "14px Arial" (decimal floored) - * - * buildFontCss({ - * fontFamily: 'Arial', - * fontSize: -5 - * }); // Returns: "1px Arial" (clamped to minimum) - * - * buildFontCss({ - * fontFamily: 'Arial', - * fontSize: 10000 - * }); // Returns: "999px Arial" (clamped to maximum) - * ``` - * - * @remarks - * - Font size is clamped to the range [1, 999] pixels - * - Decimal font sizes are floored to the nearest integer - * - Non-finite font sizes (NaN, Infinity) default to 12px - * - Multi-word font families should be provided without quotes (quotes handled by CSS) - */ -export const buildFontCss = (run: ResolvedRunProperties): string => { - const style = run.italic ? 'italic ' : ''; - const weight = run.bold ? 'bold ' : ''; - - // Validate and normalize font size - let fontSize = run.fontSize ?? 12; - if (!Number.isFinite(fontSize)) { - fontSize = 12; - } - fontSize = Math.floor(fontSize); - fontSize = Math.max(1, Math.min(999, fontSize)); - - const size = `${fontSize}px`; - const family = run.fontFamily ?? 'Times New Roman'; - return `${style}${weight}${size} ${family}`; -}; diff --git a/packages/word-layout/src/types.ts b/packages/word-layout/src/types.ts index 3e3226812a..780f1f0f48 100644 --- a/packages/word-layout/src/types.ts +++ b/packages/word-layout/src/types.ts @@ -2,8 +2,6 @@ * Shared type definitions for Word paragraph + list layout contracts. */ -import type { NumberingFormat } from './marker-utils.js'; - export type WordListSuffix = 'tab' | 'space' | 'nothing' | undefined; export type WordListJustification = 'left' | 'center' | 'right'; @@ -29,6 +27,12 @@ export type ResolvedTabStop = { decimalChar?: string; }; +type TabStop = { + val: 'start' | 'end' | 'center' | 'decimal' | 'bar' | 'clear'; + pos: number; // Twips from paragraph start (after left indent) + leader?: 'none' | 'dot' | 'hyphen' | 'heavy' | 'underscore' | 'middleDot'; +}; + export type ResolvedRunProperties = { fontFamily: string; fontSize: number; @@ -37,7 +41,7 @@ export type ResolvedRunProperties = { underline?: { style?: 'single' | 'double' | 'dotted' | 'dashed' | 'wavy'; color?: string; - }; + } | null; strike?: boolean; color?: string; highlight?: string; @@ -50,67 +54,45 @@ export type ResolvedRunProperties = { }; export type NumberingProperties = { - numId: string | number; - ilvl: number; - format?: NumberingFormat; - lvlText?: string; - markerText?: string; - lvlJc?: WordListJustification; - suffix?: WordListSuffix; - start?: number; - restart?: number; - isLgl?: boolean; - path?: number[]; - resolvedMarkerRpr?: ResolvedRunProperties; + numId?: number; + ilvl?: number; }; -export type ResolvedNumberingProperties = NumberingProperties; - export type ResolvedParagraphProperties = { styleId?: string; - alignment?: WordListJustification | 'justify' | 'distribute'; + alignment?: 'left' | 'center' | 'right' | 'justify'; indent?: ParagraphIndent; spacing?: ParagraphSpacing; - tabs?: ResolvedTabStop[]; + tabs?: TabStop[]; tabIntervalTwips?: number; decimalSeparator?: string; numberingProperties?: NumberingProperties | null; }; -export type DocDefaults = { - defaultTabIntervalTwips?: number; - decimalSeparator?: string; - run?: Partial; - paragraph?: { - indent?: ParagraphIndent; - spacing?: ParagraphSpacing; - }; -}; - export type WordLayoutMeasurementAdapter = { measureText?: (text: string, fontCss: string, options?: { letterSpacing?: number }) => number; }; +export type ListRenderingAttrs = { + markerText: string; + justification: WordListJustification; + path: number[]; + numberingType: string; + suffix: 'tab' | 'space' | 'nothing'; +}; + export type WordParagraphLayoutInput = { paragraph: ResolvedParagraphProperties; - numbering?: ResolvedNumberingProperties | null; - markerRun?: ResolvedRunProperties | null; - docDefaults: DocDefaults; - measurement?: WordLayoutMeasurementAdapter; + listRenderingAttrs: ListRenderingAttrs; + markerRun: ResolvedRunProperties; }; export type WordListMarkerLayout = { markerText: string; - glyphWidthPx?: number; - markerBoxWidthPx: number; - markerX: number; - textStartX: number; - baselineOffsetPx: number; gutterWidthPx?: number; justification: WordListJustification; suffix: WordListSuffix; run: ResolvedRunProperties; - path?: number[]; }; export type WordParagraphLayoutOutput = { @@ -120,8 +102,6 @@ export type WordParagraphLayoutOutput = { tabsPx: number[]; textStartPx: number; marker?: WordListMarkerLayout; - resolvedIndent?: ParagraphIndent; - resolvedTabs?: ResolvedTabStop[]; defaultTabIntervalPx?: number; /** * True when list uses firstLine indent pattern (marker at left+firstLine) @@ -129,11 +109,3 @@ export type WordParagraphLayoutOutput = { */ firstLineIndentMode?: boolean; }; - -export type ResolveMarkerRunPropsInput = { - inlineMarkerRpr?: Partial; - resolvedParagraphProps: ResolvedParagraphProperties; - numbering?: ResolvedNumberingProperties | null; - docDefaults: DocDefaults; - cached?: ResolvedRunProperties; -}; diff --git a/packages/word-layout/tests/marker-utils.test.ts b/packages/word-layout/tests/marker-utils.test.ts deleted file mode 100644 index 89c1ecb5a5..0000000000 --- a/packages/word-layout/tests/marker-utils.test.ts +++ /dev/null @@ -1,876 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { buildFontCss, formatMarkerText, type NumberingFormat } from '../src/marker-utils.js'; -import type { ResolvedNumberingProperties, ResolvedRunProperties } from '../src/types.js'; - -// Helper functions are not exported, so we test them through the public API -// However, we can create test helpers that mirror their behavior for direct testing - -/** - * Test helper to access toAlpha functionality through formatMarkerText - */ -const testToAlpha = (value: number, uppercase: boolean): string => { - const format: NumberingFormat = uppercase ? 'upperLetter' : 'lowerLetter'; - const numbering: ResolvedNumberingProperties = { - numId: '1', - ilvl: 0, - format, - lvlText: '%1', - path: [value], - }; - return formatMarkerText(numbering); -}; - -/** - * Test helper to access toRoman functionality through formatMarkerText - */ -const testToRoman = (value: number, uppercase: boolean): string => { - const format: NumberingFormat = uppercase ? 'upperRoman' : 'lowerRoman'; - const numbering: ResolvedNumberingProperties = { - numId: '1', - ilvl: 0, - format, - lvlText: '%1', - path: [value], - }; - return formatMarkerText(numbering); -}; - -/** - * Test helper to access formatDecimal functionality through formatMarkerText - */ -const testFormatDecimal = (value: number): string => { - const numbering: ResolvedNumberingProperties = { - numId: '1', - ilvl: 0, - format: 'decimal', - lvlText: '%1', - path: [value], - }; - return formatMarkerText(numbering); -}; - -/** - * Test helper to access applyFormat functionality through formatMarkerText - */ -const testApplyFormat = (value: number, format?: NumberingFormat): string => { - const numbering: ResolvedNumberingProperties = { - numId: '1', - ilvl: 0, - format, - lvlText: '%1', - path: [value], - }; - return formatMarkerText(numbering); -}; - -describe('formatMarkerText', () => { - it('returns empty string for null numbering', () => { - expect(formatMarkerText(null)).toBe(''); - }); - - it('returns empty string for undefined numbering', () => { - expect(formatMarkerText(undefined)).toBe(''); - }); - - it('formats bullet markers using lvlText', () => { - const numbering: ResolvedNumberingProperties = { - numId: '1', - ilvl: 0, - format: 'bullet', - lvlText: '▪', - path: [1], - }; - expect(formatMarkerText(numbering)).toBe('▪'); - }); - - it('uses default bullet when lvlText is missing', () => { - const numbering: ResolvedNumberingProperties = { - numId: '1', - ilvl: 0, - format: 'bullet', - path: [1], - }; - expect(formatMarkerText(numbering)).toBe('•'); - }); - - it('formats single-level decimal with default pattern', () => { - const numbering: ResolvedNumberingProperties = { - numId: '1', - ilvl: 0, - format: 'decimal', - path: [5], - }; - expect(formatMarkerText(numbering)).toBe('5.'); - }); - - it('formats multi-level decimal (e.g., "1.2.3.")', () => { - const numbering: ResolvedNumberingProperties = { - numId: '1', - ilvl: 2, - format: 'decimal', - lvlText: '%1.%2.%3.', - path: [1, 2, 3], - }; - expect(formatMarkerText(numbering)).toBe('1.2.3.'); - }); - - it('formats lowerLetter correctly for 1 → "a"', () => { - const numbering: ResolvedNumberingProperties = { - numId: '1', - ilvl: 0, - format: 'lowerLetter', - lvlText: '%1)', - path: [1], - }; - expect(formatMarkerText(numbering)).toBe('a)'); - }); - - it('formats lowerLetter correctly for 26 → "z"', () => { - const numbering: ResolvedNumberingProperties = { - numId: '1', - ilvl: 0, - format: 'lowerLetter', - lvlText: '%1)', - path: [26], - }; - expect(formatMarkerText(numbering)).toBe('z)'); - }); - - it('formats lowerLetter correctly for 27 → "aa"', () => { - const numbering: ResolvedNumberingProperties = { - numId: '1', - ilvl: 0, - format: 'lowerLetter', - lvlText: '%1)', - path: [27], - }; - expect(formatMarkerText(numbering)).toBe('aa)'); - }); - - it('formats upperLetter correctly for 1 → "A"', () => { - const numbering: ResolvedNumberingProperties = { - numId: '1', - ilvl: 0, - format: 'upperLetter', - lvlText: '%1)', - path: [1], - }; - expect(formatMarkerText(numbering)).toBe('A)'); - }); - - it('formats upperLetter correctly for 26 → "Z"', () => { - const numbering: ResolvedNumberingProperties = { - numId: '1', - ilvl: 0, - format: 'upperLetter', - lvlText: '%1)', - path: [26], - }; - expect(formatMarkerText(numbering)).toBe('Z)'); - }); - - it('formats upperLetter correctly for 27 → "AA"', () => { - const numbering: ResolvedNumberingProperties = { - numId: '1', - ilvl: 0, - format: 'upperLetter', - lvlText: '%1)', - path: [27], - }; - expect(formatMarkerText(numbering)).toBe('AA)'); - }); - - it('formats lowerRoman correctly for 1 → "i"', () => { - const numbering: ResolvedNumberingProperties = { - numId: '1', - ilvl: 0, - format: 'lowerRoman', - lvlText: '%1.', - path: [1], - }; - expect(formatMarkerText(numbering)).toBe('i.'); - }); - - it('formats lowerRoman correctly for 4 → "iv"', () => { - const numbering: ResolvedNumberingProperties = { - numId: '1', - ilvl: 0, - format: 'lowerRoman', - lvlText: '%1.', - path: [4], - }; - expect(formatMarkerText(numbering)).toBe('iv.'); - }); - - it('formats lowerRoman correctly for 9 → "ix"', () => { - const numbering: ResolvedNumberingProperties = { - numId: '1', - ilvl: 0, - format: 'lowerRoman', - lvlText: '%1.', - path: [9], - }; - expect(formatMarkerText(numbering)).toBe('ix.'); - }); - - it('formats upperRoman correctly for 1 → "I"', () => { - const numbering: ResolvedNumberingProperties = { - numId: '1', - ilvl: 0, - format: 'upperRoman', - lvlText: '%1.', - path: [1], - }; - expect(formatMarkerText(numbering)).toBe('I.'); - }); - - it('formats upperRoman correctly for 4 → "IV"', () => { - const numbering: ResolvedNumberingProperties = { - numId: '1', - ilvl: 0, - format: 'upperRoman', - lvlText: '%1.', - path: [4], - }; - expect(formatMarkerText(numbering)).toBe('IV.'); - }); - - it('formats upperRoman correctly for 9 → "IX"', () => { - const numbering: ResolvedNumberingProperties = { - numId: '1', - ilvl: 0, - format: 'upperRoman', - lvlText: '%1.', - path: [9], - }; - expect(formatMarkerText(numbering)).toBe('IX.'); - }); - - it('formats upperRoman correctly for 40 → "XL"', () => { - const numbering: ResolvedNumberingProperties = { - numId: '1', - ilvl: 0, - format: 'upperRoman', - lvlText: '%1.', - path: [40], - }; - expect(formatMarkerText(numbering)).toBe('XL.'); - }); - - it('formats upperRoman correctly for 90 → "XC"', () => { - const numbering: ResolvedNumberingProperties = { - numId: '1', - ilvl: 0, - format: 'upperRoman', - lvlText: '%1.', - path: [90], - }; - expect(formatMarkerText(numbering)).toBe('XC.'); - }); - - it('formats upperRoman correctly for 400 → "CD"', () => { - const numbering: ResolvedNumberingProperties = { - numId: '1', - ilvl: 0, - format: 'upperRoman', - lvlText: '%1.', - path: [400], - }; - expect(formatMarkerText(numbering)).toBe('CD.'); - }); - - it('formats upperRoman correctly for 900 → "CM"', () => { - const numbering: ResolvedNumberingProperties = { - numId: '1', - ilvl: 0, - format: 'upperRoman', - lvlText: '%1.', - path: [900], - }; - expect(formatMarkerText(numbering)).toBe('CM.'); - }); - - it('uses start value when path is missing', () => { - const numbering: ResolvedNumberingProperties = { - numId: '1', - ilvl: 0, - format: 'decimal', - lvlText: '%1.', - start: 5, - }; - expect(formatMarkerText(numbering)).toBe('5.'); - }); - - it('defaults to 1 when both path and start are missing', () => { - const numbering: ResolvedNumberingProperties = { - numId: '1', - ilvl: 0, - format: 'decimal', - lvlText: '%1.', - }; - expect(formatMarkerText(numbering)).toBe('1.'); - }); - - it('handles empty path array', () => { - const numbering: ResolvedNumberingProperties = { - numId: '1', - ilvl: 0, - format: 'decimal', - lvlText: '%1.', - path: [], - start: 3, - }; - expect(formatMarkerText(numbering)).toBe('3.'); - }); - - it('handles multi-level pattern with missing path indices', () => { - const numbering: ResolvedNumberingProperties = { - numId: '1', - ilvl: 2, - format: 'decimal', - lvlText: '%1.%2.%3.%4.', - path: [1, 2], - }; - // Should fall back to last available value or 1 - expect(formatMarkerText(numbering)).toBe('1.2.2.2.'); - }); - - describe('error handling', () => { - it('handles invalid format gracefully (defaults to decimal)', () => { - const numbering: ResolvedNumberingProperties = { - numId: '1', - ilvl: 0, - format: 'invalidFormat' as NumberingFormat, - lvlText: '%1.', - path: [5], - }; - expect(formatMarkerText(numbering)).toBe('5.'); - }); - - it('handles malformed lvlText patterns with no placeholders', () => { - const numbering: ResolvedNumberingProperties = { - numId: '1', - ilvl: 0, - format: 'decimal', - lvlText: 'no-pattern-here', - path: [5], - }; - expect(formatMarkerText(numbering)).toBe('no-pattern-here'); - }); - - it('handles malformed lvlText with invalid placeholder numbers', () => { - const numbering: ResolvedNumberingProperties = { - numId: '1', - ilvl: 0, - format: 'decimal', - lvlText: '%0.%abc.%.', - path: [5], - }; - // %0 maps to path[-1] which falls back to last value (5) - // %abc doesn't match \d+ pattern so stays as-is - // %. doesn't match \d+ pattern so stays as-is - expect(formatMarkerText(numbering)).toBe('5.%abc.%.'); - }); - - it('handles very large path indices (> 1000)', () => { - const numbering: ResolvedNumberingProperties = { - numId: '1', - ilvl: 0, - format: 'decimal', - lvlText: '%1.', - path: [10000], - }; - expect(formatMarkerText(numbering)).toBe('10000.'); - }); - - it('handles negative numbers in path (formats as-is)', () => { - const numbering: ResolvedNumberingProperties = { - numId: '1', - ilvl: 0, - format: 'decimal', - lvlText: '%1.', - path: [-5], - }; - expect(formatMarkerText(numbering)).toBe('-5.'); - }); - - it('handles zero in path', () => { - const numbering: ResolvedNumberingProperties = { - numId: '1', - ilvl: 0, - format: 'decimal', - lvlText: '%1.', - path: [0], - }; - expect(formatMarkerText(numbering)).toBe('0.'); - }); - }); -}); - -describe('buildFontCss', () => { - it('builds basic font CSS with defaults', () => { - const run: ResolvedRunProperties = { - fontFamily: 'Arial', - fontSize: 14, - }; - expect(buildFontCss(run)).toBe('14px Arial'); - }); - - it('includes bold weight', () => { - const run: ResolvedRunProperties = { - fontFamily: 'Calibri', - fontSize: 12, - bold: true, - }; - expect(buildFontCss(run)).toBe('bold 12px Calibri'); - }); - - it('includes italic style', () => { - const run: ResolvedRunProperties = { - fontFamily: 'Times New Roman', - fontSize: 11, - italic: true, - }; - expect(buildFontCss(run)).toBe('italic 11px Times New Roman'); - }); - - it('includes both bold and italic', () => { - const run: ResolvedRunProperties = { - fontFamily: 'Georgia', - fontSize: 16, - bold: true, - italic: true, - }; - expect(buildFontCss(run)).toBe('italic bold 16px Georgia'); - }); - - it('uses empty string when fontFamily is empty', () => { - const run: ResolvedRunProperties = { - fontFamily: '', - fontSize: 12, - }; - const result = buildFontCss(run); - expect(result).toBe('12px '); - }); - - it('clamps 0px fontSize to minimum of 1px', () => { - const run: ResolvedRunProperties = { - fontFamily: 'Arial', - fontSize: 0, - }; - const result = buildFontCss(run); - expect(result).toBe('1px Arial'); - }); - - it('handles undefined fontSize by using default', () => { - const run = { - fontFamily: 'Arial', - } as ResolvedRunProperties; - const result = buildFontCss(run); - expect(result).toContain('12px'); - }); - - it('handles undefined fontFamily by using default', () => { - const run = { - fontSize: 14, - } as ResolvedRunProperties; - const result = buildFontCss(run); - expect(result).toContain('Times New Roman'); - }); - - describe('font size validation', () => { - it('clamps negative fontSize to minimum of 1px', () => { - const run: ResolvedRunProperties = { - fontFamily: 'Arial', - fontSize: -5, - }; - expect(buildFontCss(run)).toBe('1px Arial'); - }); - - it('clamps very large fontSize to maximum of 999px', () => { - const run: ResolvedRunProperties = { - fontFamily: 'Arial', - fontSize: 10000, - }; - expect(buildFontCss(run)).toBe('999px Arial'); - }); - - it('floors decimal fontSize values', () => { - const run: ResolvedRunProperties = { - fontFamily: 'Arial', - fontSize: 14.7, - }; - expect(buildFontCss(run)).toBe('14px Arial'); - }); - - it('floors decimal fontSize close to next integer', () => { - const run: ResolvedRunProperties = { - fontFamily: 'Arial', - fontSize: 15.99, - }; - expect(buildFontCss(run)).toBe('15px Arial'); - }); - - it('handles NaN fontSize by using default', () => { - const run: ResolvedRunProperties = { - fontFamily: 'Arial', - fontSize: NaN, - }; - expect(buildFontCss(run)).toBe('12px Arial'); - }); - - it('handles Infinity fontSize by using default', () => { - const run: ResolvedRunProperties = { - fontFamily: 'Arial', - fontSize: Infinity, - }; - expect(buildFontCss(run)).toBe('12px Arial'); - }); - - it('handles -Infinity fontSize by using default', () => { - const run: ResolvedRunProperties = { - fontFamily: 'Arial', - fontSize: -Infinity, - }; - expect(buildFontCss(run)).toBe('12px Arial'); - }); - }); - - describe('font family edge cases', () => { - it('handles multi-word font families', () => { - const run: ResolvedRunProperties = { - fontFamily: 'Times New Roman', - fontSize: 12, - }; - expect(buildFontCss(run)).toBe('12px Times New Roman'); - }); - - it('handles font families with quotes (passed through as-is)', () => { - const run: ResolvedRunProperties = { - fontFamily: '"Courier New"', - fontSize: 12, - }; - expect(buildFontCss(run)).toBe('12px "Courier New"'); - }); - - it('handles font families with fallbacks (commas)', () => { - const run: ResolvedRunProperties = { - fontFamily: 'Arial, Helvetica, sans-serif', - fontSize: 12, - }; - expect(buildFontCss(run)).toBe('12px Arial, Helvetica, sans-serif'); - }); - - it('handles font families with special characters', () => { - const run: ResolvedRunProperties = { - fontFamily: 'Noto Sans CJK JP', - fontSize: 14, - }; - expect(buildFontCss(run)).toBe('14px Noto Sans CJK JP'); - }); - }); -}); - -describe('toAlpha (via formatMarkerText)', () => { - describe('basic conversion', () => { - it('converts 1 to "a" (lowercase)', () => { - expect(testToAlpha(1, false)).toBe('a'); - }); - - it('converts 1 to "A" (uppercase)', () => { - expect(testToAlpha(1, true)).toBe('A'); - }); - - it('converts 26 to "z" (lowercase)', () => { - expect(testToAlpha(26, false)).toBe('z'); - }); - - it('converts 26 to "Z" (uppercase)', () => { - expect(testToAlpha(26, true)).toBe('Z'); - }); - }); - - describe('multi-letter conversion', () => { - it('converts 27 to "aa" (lowercase)', () => { - expect(testToAlpha(27, false)).toBe('aa'); - }); - - it('converts 27 to "AA" (uppercase)', () => { - expect(testToAlpha(27, true)).toBe('AA'); - }); - - it('converts 52 to "az" (lowercase)', () => { - expect(testToAlpha(52, false)).toBe('az'); - }); - - it('converts 52 to "AZ" (uppercase)', () => { - expect(testToAlpha(52, true)).toBe('AZ'); - }); - - it('converts 53 to "ba" (lowercase)', () => { - expect(testToAlpha(53, false)).toBe('ba'); - }); - - it('converts 702 to "zz" (lowercase)', () => { - expect(testToAlpha(702, false)).toBe('zz'); - }); - - it('converts 702 to "ZZ" (uppercase)', () => { - expect(testToAlpha(702, true)).toBe('ZZ'); - }); - - it('converts 703 to "aaa" (lowercase)', () => { - expect(testToAlpha(703, false)).toBe('aaa'); - }); - - it('converts 703 to "AAA" (uppercase)', () => { - expect(testToAlpha(703, true)).toBe('AAA'); - }); - }); - - describe('edge cases', () => { - it('returns empty string for 0', () => { - expect(testToAlpha(0, false)).toBe(''); - }); - - it('returns empty string for negative numbers', () => { - expect(testToAlpha(-5, false)).toBe(''); - }); - - it('returns empty string for NaN', () => { - expect(testToAlpha(NaN, false)).toBe(''); - }); - - it('returns empty string for Infinity', () => { - expect(testToAlpha(Infinity, false)).toBe(''); - }); - - it('returns empty string for -Infinity', () => { - expect(testToAlpha(-Infinity, false)).toBe(''); - }); - - it('floors decimal values (5.7 becomes "e")', () => { - expect(testToAlpha(5.7, false)).toBe('e'); - }); - - it('floors decimal values (27.9 becomes "aa")', () => { - expect(testToAlpha(27.9, false)).toBe('aa'); - }); - }); - - describe('large values', () => { - it('converts 18278 to "zzz" (lowercase)', () => { - expect(testToAlpha(18278, false)).toBe('zzz'); - }); - - it('converts 18278 to "ZZZ" (uppercase)', () => { - expect(testToAlpha(18278, true)).toBe('ZZZ'); - }); - }); -}); - -describe('toRoman (via formatMarkerText)', () => { - describe('basic values', () => { - it('converts 1 to "I"', () => { - expect(testToRoman(1, true)).toBe('I'); - }); - - it('converts 2 to "II"', () => { - expect(testToRoman(2, true)).toBe('II'); - }); - - it('converts 3 to "III"', () => { - expect(testToRoman(3, true)).toBe('III'); - }); - - it('converts 5 to "V"', () => { - expect(testToRoman(5, true)).toBe('V'); - }); - - it('converts 10 to "X"', () => { - expect(testToRoman(10, true)).toBe('X'); - }); - - it('converts 50 to "L"', () => { - expect(testToRoman(50, true)).toBe('L'); - }); - - it('converts 100 to "C"', () => { - expect(testToRoman(100, true)).toBe('C'); - }); - - it('converts 500 to "D"', () => { - expect(testToRoman(500, true)).toBe('D'); - }); - - it('converts 1000 to "M"', () => { - expect(testToRoman(1000, true)).toBe('M'); - }); - }); - - describe('subtractive notation', () => { - it('converts 4 to "IV"', () => { - expect(testToRoman(4, true)).toBe('IV'); - }); - - it('converts 9 to "IX"', () => { - expect(testToRoman(9, true)).toBe('IX'); - }); - - it('converts 40 to "XL"', () => { - expect(testToRoman(40, true)).toBe('XL'); - }); - - it('converts 90 to "XC"', () => { - expect(testToRoman(90, true)).toBe('XC'); - }); - - it('converts 400 to "CD"', () => { - expect(testToRoman(400, true)).toBe('CD'); - }); - - it('converts 900 to "CM"', () => { - expect(testToRoman(900, true)).toBe('CM'); - }); - }); - - describe('large values', () => { - it('converts 1994 to "MCMXCIV"', () => { - expect(testToRoman(1994, true)).toBe('MCMXCIV'); - }); - - it('converts 2024 to "MMXXIV"', () => { - expect(testToRoman(2024, true)).toBe('MMXXIV'); - }); - - it('converts 3999 to "MMMCMXCIX"', () => { - expect(testToRoman(3999, true)).toBe('MMMCMXCIX'); - }); - - it('converts values beyond standard range (4000)', () => { - // While non-standard, the function should still produce output - expect(testToRoman(4000, true)).toBe('MMMM'); - }); - }); - - describe('uppercase/lowercase', () => { - it('converts 4 to "iv" (lowercase)', () => { - expect(testToRoman(4, false)).toBe('iv'); - }); - - it('converts 1994 to "mcmxciv" (lowercase)', () => { - expect(testToRoman(1994, false)).toBe('mcmxciv'); - }); - }); - - describe('edge cases', () => { - it('returns empty string for 0', () => { - expect(testToRoman(0, true)).toBe(''); - }); - - it('returns empty string for negative numbers', () => { - expect(testToRoman(-5, true)).toBe(''); - }); - - it('returns empty string for NaN', () => { - expect(testToRoman(NaN, true)).toBe(''); - }); - - it('returns empty string for Infinity', () => { - expect(testToRoman(Infinity, true)).toBe(''); - }); - - it('returns empty string for -Infinity', () => { - expect(testToRoman(-Infinity, true)).toBe(''); - }); - - it('floors decimal values (4.7 becomes "IV")', () => { - expect(testToRoman(4.7, true)).toBe('IV'); - }); - }); -}); - -describe('formatDecimal (via formatMarkerText)', () => { - describe('basic formatting', () => { - it('formats 1 as "1"', () => { - expect(testFormatDecimal(1)).toBe('1'); - }); - - it('formats 42 as "42"', () => { - expect(testFormatDecimal(42)).toBe('42'); - }); - - it('formats 0 as "0"', () => { - expect(testFormatDecimal(0)).toBe('0'); - }); - - it('formats negative numbers correctly', () => { - expect(testFormatDecimal(-5)).toBe('-5'); - }); - }); - - describe('decimal values', () => { - it('floors 3.7 to "3"', () => { - expect(testFormatDecimal(3.7)).toBe('3'); - }); - - it('floors 9.99 to "9"', () => { - expect(testFormatDecimal(9.99)).toBe('9'); - }); - - it('floors negative decimals (-3.7 to "-4")', () => { - expect(testFormatDecimal(-3.7)).toBe('-4'); - }); - }); - - describe('edge cases', () => { - it('returns empty string for NaN', () => { - expect(testFormatDecimal(NaN)).toBe(''); - }); - - it('returns empty string for Infinity', () => { - expect(testFormatDecimal(Infinity)).toBe(''); - }); - - it('returns empty string for -Infinity', () => { - expect(testFormatDecimal(-Infinity)).toBe(''); - }); - }); -}); - -describe('applyFormat (via formatMarkerText)', () => { - describe('all format types', () => { - it('applies decimal format', () => { - expect(testApplyFormat(5, 'decimal')).toBe('5'); - }); - - it('applies lowerLetter format', () => { - expect(testApplyFormat(5, 'lowerLetter')).toBe('e'); - }); - - it('applies upperLetter format', () => { - expect(testApplyFormat(5, 'upperLetter')).toBe('E'); - }); - - it('applies lowerRoman format', () => { - expect(testApplyFormat(5, 'lowerRoman')).toBe('v'); - }); - - it('applies upperRoman format', () => { - expect(testApplyFormat(5, 'upperRoman')).toBe('V'); - }); - }); - - describe('undefined format', () => { - it('defaults to decimal when format is undefined', () => { - expect(testApplyFormat(42, undefined)).toBe('42'); - }); - }); - - describe('unknown format', () => { - it('defaults to decimal for unknown format', () => { - expect(testApplyFormat(42, 'unknownFormat' as NumberingFormat)).toBe('42'); - }); - }); -}); diff --git a/packages/word-layout/tests/word-layout.test.ts b/packages/word-layout/tests/word-layout.test.ts index f4107d5df6..1e0de992f5 100644 --- a/packages/word-layout/tests/word-layout.test.ts +++ b/packages/word-layout/tests/word-layout.test.ts @@ -1,92 +1,59 @@ import { describe, expect, it } from 'vitest'; -import { computeWordParagraphLayout, resolveMarkerRunProperties } from '../src/index.js'; +import { computeWordParagraphLayout, DEFAULT_LIST_HANGING_PX } from '../src/index.js'; import type { WordParagraphLayoutInput } from '../src/types.js'; -const buildInput = (overrides: Partial = {}): WordParagraphLayoutInput => { - const numbering = overrides.numbering ?? { - numId: '1', - ilvl: 0, - format: 'decimal', - lvlText: '%1.', - suffix: 'tab', - lvlJc: 'left', +const buildInput = (overrides: Partial = {}): WordParagraphLayoutInput => ({ + paragraph: { + indent: { left: 36, hanging: 18 }, + tabs: [{ pos: 1080, val: 'start' }], + tabIntervalTwips: 720, + numberingProperties: { numId: 1, ilvl: 0 }, + }, + listRenderingAttrs: { + markerText: '3.', + justification: 'left', path: [3], - }; - - return { - paragraph: { - indent: { left: 36, hanging: 18 }, - tabs: [{ position: 72, alignment: 'start' }], - tabIntervalTwips: 720, - numberingProperties: numbering, - }, - numbering, - docDefaults: { - defaultTabIntervalTwips: 720, - run: { fontFamily: 'Calibri', fontSize: 12 }, - }, - measurement: { - measureText: (text: string) => text.length * 6, - }, - ...overrides, - }; -}; + numberingType: 'decimal', + suffix: 'tab', + }, + markerRun: { fontFamily: 'Calibri', fontSize: 12 }, + ...overrides, +}); describe('computeWordParagraphLayout', () => { - it('computes marker layout with measurement and numbering data', () => { + it('computes marker layout with list rendering data', () => { const layout = computeWordParagraphLayout(buildInput()); expect(layout.indentLeftPx).toBe(36); + expect(layout.hangingPx).toBe(18); expect(layout.tabsPx).toEqual([72]); - expect(layout.defaultTabIntervalPx).toBe(48); + expect(layout.defaultTabIntervalPx).toBe(720); expect(layout.marker?.markerText).toBe('3.'); - expect(layout.marker?.glyphWidthPx).toBe(12); - expect(layout.marker?.markerBoxWidthPx).toBeGreaterThan(12); - expect(layout.marker?.markerX).toBeCloseTo(layout.textStartPx - (layout.marker?.markerBoxWidthPx ?? 0)); + expect(layout.marker?.justification).toBe('left'); + expect(layout.marker?.suffix).toBe('tab'); expect(layout.marker?.run.fontFamily).toBe('Calibri'); }); - it('formats roman numerals and falls back when measurement missing', () => { + it('accepts preformatted marker text and list alignment', () => { const layout = computeWordParagraphLayout( buildInput({ - numbering: { - numId: '2', - ilvl: 0, - format: 'upperRoman', - lvlText: '%1)', - suffix: 'space', - lvlJc: 'right', + listRenderingAttrs: { + markerText: 'IV)', + justification: 'right', path: [4], + numberingType: 'upperRoman', + suffix: 'space', }, - measurement: undefined, }), ); expect(layout.marker?.markerText).toBe('IV)'); - expect(layout.marker?.glyphWidthPx).toBeUndefined(); - expect(layout.marker?.markerBoxWidthPx).toBeGreaterThan(0); expect(layout.marker?.justification).toBe('right'); expect(layout.marker?.suffix).toBe('space'); }); }); -describe('resolveMarkerRunProperties', () => { - it('merges defaults with inline overrides when cache missing', () => { - const run = resolveMarkerRunProperties({ - inlineMarkerRpr: { fontFamily: 'Roboto', bold: true }, - resolvedParagraphProps: { indent: {} }, - numbering: null, - docDefaults: { run: { fontSize: 14, color: '#333333', fontFamily: 'Calibri' } }, - }); - - expect(run.fontFamily).toBe('Roboto'); - expect(run.fontSize).toBe(14); - expect(run.bold).toBe(true); - expect(run.color).toBe('#333333'); - }); -}); - describe('computeWordParagraphLayout edge cases', () => { it('handles paragraph without numbering properties', () => { const layout = computeWordParagraphLayout( @@ -94,44 +61,14 @@ describe('computeWordParagraphLayout edge cases', () => { paragraph: { indent: { left: 24 }, tabs: [], - }, - numbering: null, - }), - ); - - expect(layout.marker).toBeUndefined(); - expect(layout.indentLeftPx).toBe(24); - expect(layout.hangingPx).toBe(0); - }); - - it('handles null inputs for numbering override but uses paragraph numbering', () => { - const layout = computeWordParagraphLayout( - buildInput({ - numbering: null, - paragraph: { - indent: { left: 36, hanging: 18 }, - tabs: [], numberingProperties: null, }, }), ); - expect(layout.marker).toBeUndefined(); - }); - - it('handles undefined inputs for numbering override but uses paragraph numbering', () => { - const layout = computeWordParagraphLayout( - buildInput({ - numbering: undefined, - paragraph: { - indent: { left: 36, hanging: 18 }, - tabs: [], - numberingProperties: null, - }, - }), - ); - - expect(layout.marker).toBeUndefined(); + expect(layout.marker?.markerText).toBe('3.'); + expect(layout.indentLeftPx).toBe(24); + expect(layout.hangingPx).toBe(DEFAULT_LIST_HANGING_PX); }); it('handles negative indent values', () => { @@ -142,42 +79,40 @@ describe('computeWordParagraphLayout edge cases', () => { tabs: [], numberingProperties: null, }, - numbering: null, }), ); expect(layout.indentLeftPx).toBe(-10); - expect(layout.hangingPx).toBe(0); // hanging is clamped to >= 0 + expect(layout.hangingPx).toBe(-5); }); - it('handles both firstLine and hanging defined (firstLine takes precedence)', () => { + it('handles both firstLine and hanging defined (hanging disables firstLine mode)', () => { const layout = computeWordParagraphLayout( buildInput({ paragraph: { indent: { left: 36, firstLine: 12, hanging: 18 }, tabs: [], }, - numbering: null, }), ); expect(layout.firstLinePx).toBe(12); - expect(layout.hangingPx).toBe(18); // hanging is preserved in this case + expect(layout.hangingPx).toBe(18); + expect(layout.firstLineIndentMode).toBeUndefined(); }); - it('handles negative firstLine as hanging', () => { + it('handles negative firstLine without enabling firstLine mode', () => { const layout = computeWordParagraphLayout( buildInput({ paragraph: { indent: { left: 36, firstLine: -18 }, tabs: [], }, - numbering: null, }), ); expect(layout.firstLinePx).toBe(-18); - expect(layout.hangingPx).toBe(18); // converted from negative firstLine + expect(layout.firstLineIndentMode).toBeUndefined(); }); it('handles empty tabs array', () => { @@ -226,44 +161,29 @@ describe('computeWordParagraphLayout edge cases', () => { indent: { left: 0, hanging: 0 }, tabs: [], }, - numbering: null, }), ); expect(layout.indentLeftPx).toBe(0); - expect(layout.hangingPx).toBe(0); + expect(layout.hangingPx).toBe(DEFAULT_LIST_HANGING_PX); }); - it('handles undefined indent with defaults from docDefaults', () => { + it('handles undefined indent with defaults falling back to zero', () => { const layout = computeWordParagraphLayout( buildInput({ paragraph: { tabs: [], numberingProperties: null, }, - numbering: null, - docDefaults: {}, }), ); - // When indent.left is undefined, the code uses ?? 0, so it becomes 0 expect(layout.indentLeftPx).toBe(0); }); - - it('handles missing measurement adapter', () => { - const layout = computeWordParagraphLayout( - buildInput({ - measurement: undefined, - }), - ); - - expect(layout.marker?.glyphWidthPx).toBeUndefined(); - expect(layout.marker?.markerBoxWidthPx).toBeGreaterThan(0); - }); }); describe('firstLineIndentMode detection and behavior', () => { - it('should detect firstLine indent pattern when firstLine > 0 and no hanging', () => { + it('detects firstLine indent pattern when firstLine > 0 and no hanging', () => { const layout = computeWordParagraphLayout( buildInput({ paragraph: { @@ -278,7 +198,7 @@ describe('firstLineIndentMode detection and behavior', () => { expect(layout.hangingPx).toBe(0); }); - it('should NOT detect firstLine mode when firstLine is 0', () => { + it('does NOT detect firstLine mode when firstLine is 0', () => { const layout = computeWordParagraphLayout( buildInput({ paragraph: { @@ -292,7 +212,7 @@ describe('firstLineIndentMode detection and behavior', () => { expect(layout.firstLinePx).toBe(0); }); - it('should NOT detect firstLine mode when firstLine is undefined', () => { + it('does NOT detect firstLine mode when firstLine is undefined', () => { const layout = computeWordParagraphLayout( buildInput({ paragraph: { @@ -306,7 +226,7 @@ describe('firstLineIndentMode detection and behavior', () => { expect(layout.firstLinePx).toBeUndefined(); }); - it('should NOT detect firstLine mode when hanging is also defined', () => { + it('does NOT detect firstLine mode when hanging is also defined', () => { const layout = computeWordParagraphLayout( buildInput({ paragraph: { @@ -321,30 +241,23 @@ describe('firstLineIndentMode detection and behavior', () => { expect(layout.hangingPx).toBe(360); }); - it('should calculate textStartPx correctly in firstLine mode', () => { + it('calculates textStartPx correctly in firstLine mode', () => { const layout = computeWordParagraphLayout( buildInput({ paragraph: { indent: { left: 0, firstLine: 720 }, tabs: [], }, - measurement: { - measureText: (text: string) => text.length * 6, - }, }), ); expect(layout.firstLineIndentMode).toBe(true); expect(layout.indentLeftPx).toBe(0); expect(layout.firstLinePx).toBe(720); - - // textStartPx should be: left (0) + firstLine (720) + markerBoxWidth - // markerBoxWidth = glyphWidthPx (12) + LIST_MARKER_GAP (8) = 20 - expect(layout.textStartPx).toBe(740); // 0 + 720 + 20 - expect(layout.marker?.markerX).toBe(720); // left (0) + firstLine (720) + expect(layout.textStartPx).toBe(720 + DEFAULT_LIST_HANGING_PX); }); - it('should handle negative firstLine as hanging (not firstLine mode)', () => { + it('handles negative firstLine without enabling firstLine mode', () => { const layout = computeWordParagraphLayout( buildInput({ paragraph: { @@ -356,10 +269,9 @@ describe('firstLineIndentMode detection and behavior', () => { expect(layout.firstLineIndentMode).toBeUndefined(); expect(layout.firstLinePx).toBe(-360); - expect(layout.hangingPx).toBe(360); // converted from negative firstLine }); - it('should handle very small positive firstLine correctly', () => { + it('handles very small positive firstLine correctly', () => { const layout = computeWordParagraphLayout( buildInput({ paragraph: { @@ -373,42 +285,45 @@ describe('firstLineIndentMode detection and behavior', () => { expect(layout.firstLinePx).toBe(1); }); - it('should handle NaN firstLine gracefully (not detected as firstLine mode)', () => { + it('handles NaN firstLine gracefully (not detected as firstLine mode)', () => { const layout = computeWordParagraphLayout( buildInput({ paragraph: { - indent: { left: 360, firstLine: NaN }, + indent: { left: 360, firstLine: Number.NaN }, tabs: [], }, }), ); expect(layout.firstLineIndentMode).toBeUndefined(); + expect(Number.isNaN(layout.firstLinePx)).toBe(true); }); - it('should handle Infinity firstLine gracefully (not detected as firstLine mode)', () => { + it('handles Infinity firstLine gracefully (not detected as firstLine mode)', () => { const layout = computeWordParagraphLayout( buildInput({ paragraph: { - indent: { left: 360, firstLine: Infinity }, + indent: { left: 360, firstLine: Number.POSITIVE_INFINITY }, tabs: [], }, }), ); expect(layout.firstLineIndentMode).toBeUndefined(); + expect(layout.firstLinePx).toBe(Number.POSITIVE_INFINITY); }); - it('should handle -Infinity firstLine gracefully (not detected as firstLine mode)', () => { + it('handles -Infinity firstLine gracefully (not detected as firstLine mode)', () => { const layout = computeWordParagraphLayout( buildInput({ paragraph: { - indent: { left: 360, firstLine: -Infinity }, + indent: { left: 360, firstLine: Number.NEGATIVE_INFINITY }, tabs: [], }, }), ); expect(layout.firstLineIndentMode).toBeUndefined(); + expect(layout.firstLinePx).toBe(Number.NEGATIVE_INFINITY); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6eed3029a..88de72e54b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -605,6 +605,9 @@ importers: '@superdoc/contracts': specifier: workspace:* version: link:../contracts + '@superdoc/font-utils': + specifier: workspace:* + version: link:../../../shared/font-utils '@superdoc/locale-utils': specifier: workspace:* version: link:../../../shared/locale-utils diff --git a/shared/common/list-numbering/index.test.ts b/shared/common/list-numbering/index.test.ts index 65d47f6fef..3cfa0373a8 100644 --- a/shared/common/list-numbering/index.test.ts +++ b/shared/common/list-numbering/index.test.ts @@ -11,6 +11,22 @@ describe('generateOrderedListIndex', () => { expect(result).toBe('.12.4)'); }); + it('formats decimalZero markers with leading zeros for single digits', () => { + const singleDigit = generateOrderedListIndex({ + listLevel: [1, 1], + lvlText: '%1.%2', + listNumberingType: 'decimalZero', + }); + expect(singleDigit).toBe('1.01'); + + const doubleDigit = generateOrderedListIndex({ + listLevel: [1, 10], + lvlText: '%1.%2', + listNumberingType: 'decimalZero', + }); + expect(doubleDigit).toBe('1.10'); + }); + it('formats lower roman numerals', () => { const result = generateOrderedListIndex({ listLevel: [4], diff --git a/shared/common/list-numbering/index.ts b/shared/common/list-numbering/index.ts index 5dac90f83c..a066f304d4 100644 --- a/shared/common/list-numbering/index.ts +++ b/shared/common/list-numbering/index.ts @@ -1,6 +1,6 @@ type NumberingHandler = (path: number[], lvlText: string, customFormat?: string) => string | null; -type NumberFormatter = (value: number) => string; +type NumberFormatter = (value: number, idx?: number) => string; const handleDecimal: NumberingHandler = (path, lvlText) => generateNumbering(path, lvlText, numberToStringFormatter); const handleRoman: NumberingHandler = (path, lvlText) => generateNumbering(path, lvlText, intToRoman); @@ -18,9 +18,11 @@ const handleCustom: NumberingHandler = (path, lvlText, customFormat) => generateFromCustom(path, lvlText, customFormat as string); const handleJapaneseCounting: NumberingHandler = (path, lvlText) => generateNumbering(path, lvlText, intToJapaneseCounting); +const handleDecimalZero: NumberingHandler = (path, lvlText) => generateNumbering(path, lvlText, decimalZeroFormatter); const listIndexMap: Record = { decimal: handleDecimal, + decimalZero: handleDecimalZero, lowerRoman: handleLowerRoman, upperRoman: handleRoman, lowerLetter: handleLowerAlpha, @@ -56,7 +58,7 @@ const createNumbering = (values: string[], lvlText: string): string => { }; const generateNumbering = (path: number[], lvlText: string, formatter: NumberFormatter): string => { - const formattedValues = path.map((entry) => formatter(entry)); + const formattedValues = path.map((entry, idx) => formatter(entry, idx)); return createNumbering(formattedValues, lvlText); }; @@ -67,6 +69,11 @@ const ordinalFormatter: NumberFormatter = (value) => { return `${value}${suffix}`; }; +const decimalZeroFormatter: NumberFormatter = (value, idx) => { + if (value >= 10 || idx === 0) return String(value); + return `0${value}`; +}; + const generateFromCustom = (path: number[], lvlText: string, customFormat: string): string => { if (customFormat.match(/(?:[0]+\d,\s){3}\.{3}/) == null) { return generateNumbering(path, lvlText, numberToStringFormatter);