Skip to content

fix(docx-io): keep figures and list paragraphs in Word export - #5331

Merged
zbeyens merged 1 commit into
mainfrom
docx-io/figures-and-list-paragraphs
Nov 4, 2026
Merged

zbeyens merged 1 commit into
mainfrom
docx-io/figures-and-list-paragraphs

Conversation

@zbeyens

@zbeyens zbeyens commented Nov 4, 2026 •

Copy link
Copy Markdown
Member
  • Auto release

This carries @tarasromil's #5330 with my review fixes on top, because that PR does not allow maintainer edits. Taras is the commit's co-author.

Word export keeps images and captions in lists and table cells, and each paragraph in a list item stays separate. My fix puts an item's first image on its bullet, keeps line breaks in the item, and stops renumbering.

Caution

It only touches how the Word exporter writes lists and figures, and a test covers each case my fix changes. Nobody opened the file in Word, only Quick Look.

How it works

A Word file stores a list as paragraphs. The first paragraph of an item carries the bullet. Any later paragraph in that item has no bullet and is indented to line up with the item's text. The exporter turns the editor's HTML into those paragraphs.

Before the PR, the list code folded everything in an item into one paragraph, and a figure inside it vanished. The PR gives each block its own paragraph and exports figures. But it split on any tag outside a short list of inline tags, so a line break started a new paragraph. A figure that opened an item also lost its bullet.

My fix treats the image and the caption as two blocks of the item. The first block takes the bullet, every later block is indented, and inline content stays in the paragraph before it.

On top of the PR

  • Kept
    • One figure builder for figures in the body and in table cells, with captions and their bold and italic.
    • Each block paragraph of a list item is its own paragraph, indented with no bullet after the first.
    • Images fit the width of their table cell or list item, from the author's second commit, with its TABLE_CELL_MARGIN and the maximumWidth that buildList takes.
    • The PR's slow tests and its changeset.
  • Changed
    • Inline content of a list item, such as a line break, kbd or an inline image, stays in the item's paragraph, as on main. A paragraph, a div, a figure or another block tag from the file's SPECIAL_BLOCK_ELEMENTS, such as a blockquote or pre, starts a new one.
    • A list item keeps its number when its figure starts with an image the exporter cannot fetch or with a table, or has no image, table or caption at all. An empty numbered paragraph holds the item's place, as on main, so later items do not renumber.
    • In a list, a figure's image, caption and table are separate blocks of the item, so a figure that opens an item carries the bullet. One buildFigurePart exports a part for both buildFigure and buildList.
    • buildImage keeps the author's single attributes parameter, typed as the exported ParagraphAttributes, but uses its maximumWidth as given. Each caller passes the width left after the paragraph's indent, which is the rule inline images already follow. So every block of a list item gets the item's text width, whether its bullet or an indent sets where it starts.
    • getWrappedTable and the PR's getWrappedFigure become one getWrappedElement(node, tag) in xml-builder.ts, which unwraps single-child divs only. This keeps the PR from adding names to the existing import cycle.
    • That unwrap ignores the hidden data-slate-spacer span Plate's static render puts beside a void's content. A Plate image in a list item sits in such a div, so both the PR and my first repair still dropped it, while hand-written <div><figure> markup passed. Body and cell output do not change.
    • getListTextIndent(level) in utils/list.ts gives the numbering, the continuation paragraphs and the list's image width one indent rule.
    • buildTableCell's two block loops become one importCellBlock, because the inner loop for a div's children repeated the outer loop's branches.
    • buildList types its queue as elements only and drops the checks that could never fail, and its width never drops below 1 twip, like the cell's.
    • Seven slow tests on one row helper: line breaks and inline elements stay in one list paragraph, a blockquote and a pre after a paragraph start their own paragraphs, a list item that opens with a figure gets the bullet while the rest is indented, Plate's static image beside its void spacer stays in its list item, an item keeps its number when its figure starts with an unfetchable image or a table or has nothing to export, a wide image that opens an item fits the item's text width, and a table inside a figure in a list item stays a table.
  • Dropped
    • Nothing.

Demo

  1. Export a document whose list has a logo first in an item, a chart between two paragraphs, and a line with a line break, followed by a table cell holding a captioned image. Open the file with Quick Look on a Mac. On main, the first item is an empty bullet, both images and the text after them are gone, and so is the cell's image. With the PR and my fix, every image and caption shows, the logo sits on its item's bullet, and the line break stays inside one bullet.

Before

before

After

after

  1. Export the same document from the PR's head without my fix. The logo's item has no bullet, and the shortcut line splits into three lines. With my fix, the logo carries the bullet and the shortcut line is one bullet.

Before

before

After

after

Verification

  • On the final code, the package's lint, typecheck and build pass, along with 105 fast tests and 89 slow tests. Each new slow test failed before its fix, either on the PR's head or on my earlier round that lacked it.
  • A script that exports Plate's own image markup shows a list item now keeps its image and caption, and the body and table cell output is the same as on the PR's head. A second script over 10 hand-written edge cases gives the same output as my first round.
  • Quick Look drew the exported file on a Mac. Nobody opened it in Word, and Plate v2 on next is unchanged.

Out of scope

  • The same list fix for Plate v2 on next, which comes in its own change once this PR merges.

Word export now keeps figures inside table cells and list items, with
their captions and their bold and italic. Each block paragraph of a
list item is its own paragraph. The first one carries the bullet, and
later ones take the item's indent. Images fit the width of their table
cell or list item.

Inside a list item, line breaks, kbd and inline images stay in the
item's paragraph, an image that starts the item sits on its bullet, and
the item keeps its number when its figure has nothing to export.
Plate's own static image, which sits in its void's div beside a hidden
spacer, stays in its list item too.

This carries #5330, which does not allow maintainer edits,
with review fixes on top.

Co-authored-by: Taras Romilovych <tarasromil@gmail.com>
@zbeyens
zbeyens requested a review from a team November 4, 2026 16:33
@codesandbox

codesandbox Bot commented Nov 4, 2026

Copy link
Copy Markdown

Review or Edit in CodeSandbox

Open the branch in Web Editor • VS Code • Insiders

Open Preview

@changeset-bot

changeset-bot Bot commented Nov 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 03dd7c7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@platejs/docx-io Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@zbeyens
zbeyens merged commit 521a967 into main Nov 4, 2026
9 checks passed
@zbeyens
zbeyens deleted the docx-io/figures-and-list-paragraphs branch November 4, 2026 16:58
zbeyens added a commit that referenced this pull request Nov 4, 2026
…mmit

When a PR's head branch does not allow maintainer edits, or a push to
it is refused, babysit now folds the PR's commits and the repair into
one commit, with a Co-authored-by line for each author, and delivers
it into the PR's base. A PR into next lands through land.mjs, and a PR
into main gets our own PR. Once that PR is open or the commit has
landed, the run closes the author's PR with a comment that links its
replacement. Before this, the run opened a PR stacked on the author's
branch, which only the author could merge.

Before the run first installs or runs the code of a PR whose author is
not on the team, locally or in CI, a security review reads every line
the PR adds, and each later commit of theirs gets the same read before
it runs. An author's change to a workflow, a package script, a build or
test config, a lockfile or a dependency waits for the owner's word, and
so does a line the review finds reading a secret or the environment, or
calling the network. Commits the author adds before the close join our
PR as one more commit that holds only their own changes, with its own
review round.

A babysit merge into main that would publish the pending changesets
now adds the skip-release marker to its squash subject and names the
pending release in the hand-back, because a release needs the owner's
word. A review thread whose reply waits as a draft no longer blocks
the merge, and a verdict that drops part of the change no longer stops
it. Another person's assignment on the PR or on its issue stops the run
on any PR, not only an issue PR.

A main PR's repair now runs the bug-fix playbook on the PR's defect,
installs dependencies with ../plate's own pnpm install, and its v2
port carries a Co-authored-by line for each author whose code it
keeps.

Claude Code and Codex both read this playbook, so a session in either
runtime that babysits a contributor's PR now carries it in our own PR
instead of stacking one on the contributor's branch.

The plan records that PR 5330 shipped as #5331, merged as
521a967 with Taras Romilovych as co-author, and that the Plate v2
port of the same fix comes next.

Beyond the ask: the main-sync row now takes the number of the PR that
merged, and main-sync.mjs matches that number only as the last (#N)
before markers such as [skip release], so a revert subject that quotes
an older PR's number no longer marks that older row. The merge
read-back checks the Co-authored-by lines, and suggested task
t_48de3f0b moves the carry rule into the shared land rule for every
pstack project.

Session: local_532137ea-4141-4034-8100-f563b8a8393a
Plan: docs/plans/2026-11-04-pr-5330-babysit.md
Lead: unknown

This branch was successfully deployed

1 active deployment
Preview — 03dd7c77 Deployed Nov 4, 2026 by github-actions[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant