Skip to content

fix(docx): hang a list item's marker into the margin by its signed indent - #2387

Merged
developer0hye merged 1 commit into
mainfrom
fix/docx-negative-list-indent
Oct 20, 2026
Merged

developer0hye merged 1 commit into
mainfrom
fix/docx-negative-list-indent

Conversation

@developer0hye

Copy link
Copy Markdown
Owner

File submission policy

Before attaching or committing files, read the submission policy.
Complete a security review and obtain your organization's internal approval for
public sharing where applicable. You are responsible for lawful disclosure;
the project and maintainers disclaim related liability to the extent permitted
by applicable law.

  • Any submitted sample files or attachments satisfy the submission policy, or none are submitted.

Summary

Second milestone of #2233: body list items now take a signed indent. Word puts a list item's marker at the margin plus its w:ind w:left minus its hanging span, and its lines at the margin plus w:left, even when that lies left of the margin. paragraph_list_indent clamped both the left indent and the marker origin at zero. When the marker origin clamped onto the left indent, it returned no geometry at all, and Typst's default enum layout took over. In docxide-pdf case97 (w:left="-100" w:hanging="5"), Word draws "1." at 66.775pt and the tabbed body at 80.525pt; main drew them at 72pt and 88pt. They now land at 66.75pt and 80.55pt.

Corpus scan

Base ca194434 and candidate release CLIs were built in separate run-owned targets (cmp differ). Both converted the 648 DOCX files that have a numbering part (fixtures-v1 bulk corpus, docxide-pdf fe7b8bea cases/ and scraped/) plus 40 random documents without one. I compared mutool draw -F stext per-glyph positions. All 688 conversions succeeded on both builds. The controls and the three gated documents (slovak, poi/checkboxes, libreoffice/2129393649) are unchanged. Four documents moved:

Document Change Reference
docxide case97 "1." 72 → 66.75, body 88 → 80.55 Word 66.775 / 80.525
bulk libreoffice/tdf123054 (w:ind w:left="0" w:hanging="284") first body glyph 83.35 → 70.85; wrapped lines (page 4) 87.34 → 70.85; "I." 70.85 → 56.65 LibreOffice 24.2 (labelled): body 70.95, "I." ends at the 56.65pt origin because its level is lvlJc="right", filed as #2385
bulk poi/bug65649 p13 (w:left="0", level hanging 18.75pt) "3." 85.05 → 66.3, body stays 85.05 LibreOffice centres the whole line (w:jc="center"), which list items ignore before and after; filed as #2386
bulk poi/bug59058 p54 an empty item's "o" 72 → 54 (w:left="0", hanging 18pt) the paragraph mark is w:vanish; under the paragraph-mark rule in #2384 Word would hide this bullet (no Word reference)

Follow-ups filed with compare images in this PR: #2383 (style indent beats a direct numbering level), #2384 (marker formatted from the runs instead of the paragraph mark), #2385 (w:lvlJc ignored) and #2386 (list items ignore w:jc).

Related issue

Related: #2233. The issue stays open: table-cell paragraphs and lists still clamp until cells clip at their edge (#2261).

Testing

  • cargo test --locked -p office2pdf --profile ci --lib -- docx_list_marker_and_body_hang docx_list_hanging_into_the_margin: both new tests failed on the unfixed code (marker at 85.05pt instead of 31.05pt; "1." at 72pt instead of 66.775pt) and pass now.
  • docx_style_indent_under_direct_numbering_keeps_the_marker_at_the_margin fails with the gate disabled (marker at 67.05pt) and passes with it.
  • cargo test --locked --workspace --profile ci: all suites pass (office2pdf lib 3863 passed, 0 failed).
  • cargo fmt --all -- --check; cargo clippy --locked --workspace --all-targets -- -D warnings: clean.
  • Corpus scan and evidence as described above and below.

Visual impact

  • No rendered PDF change
  • Rendered PDF change or visual evidence added
  • Reason: Not applicable; list item markers and lines move.

Visual audit

Visual comparison

GT Before After
GT Before After

Required inspection

  • Rendered all evidence at 150 DPI or higher
  • Stored progressive JPEG quality 86 assets with metadata stripped
  • Used Codex/Claude vision to inspect the full GT/output pages, diff, and matched crops
  • Inspected matched region crops at full resolution
  • Ran compare_layout.py --audit --fine-shift PT and dispositioned every fine/large text-instance shift, rectangle geometry deviation, painted-text visibility mismatch, and visible-fill occlusion
  • Ran compare_render.py --cluster-report PATH --strict-clusters and dispositioned every material 5% fuzz diff cluster by explicit ID
  • Inventoried hairlines and border dash styles
  • Inventoried font weight, italic, and underline emphasis

Deviation audit

Check Result
Page count/order Matches GT: one page
Element presence Matches GT: marker and body text present
Position/size Remaining: #1874. The marker (66.75pt vs Word 66.775pt) and body (80.55pt vs 80.525pt) are fixed. "est marker" sits 1.07pt right of Word's pair-kerned T-e
Rotation/flip No deviation observed
Fill No deviation observed
Stroke/border No deviation observed
Shape outline geometry No deviation observed
Text content Matches GT: normalized text identical (compare_text_layer.py)
Font family/weight/style Remaining: #2384. The marker is Times New Roman 14pt where Word uses the paragraph mark's Aptos 12pt
Text color Matches GT: black
Alignment Matches GT: left-aligned line
Line/paragraph spacing Matches GT: baseline within 0.21pt
Clipping/overflow Matches GT: the marker hangs 5.25pt into the margin, unclipped, as in Word

Final performance pass

  • Status: Complete
  • Measurements: release CLIs from base ca194434 and this branch, built in separate run-owned targets (cmp differ). /usr/bin/time -f "%e %M" (elapsed s, peak RSS); one warm-up pass, then 12 interleaved base/branch runs per input, warm page cache, output to /tmp. Intel i5-10400 (12 threads), Linux 7.0.0-38, dev-box fonts (Liberation/Noto substitutes, no MS core fonts). Medians, base → branch:
    • case97 (reported): 0.260 → 0.260 s, 42.2 → 42.2 MiB.
    • bulk tdf123054 (mover): 0.465 → 0.460 s, 57.3 → 57.1 MiB.
    • bulk bug65649 (178 pages, list-heavy, mover): 4.325 → 4.300 s, 1152.3 → 1138.7 MiB.
    • control spaced_section_mark.docx (unchanged output): 0.265 → 0.260 s, 42.4 → 42.3 MiB.
  • Outcome and revalidation: no measurable cost. The change adds one style lookup per directly numbered paragraph and a sign check in the list geometry, and every delta is within run-to-run noise (RSS noise on this box reaches about 3 MiB on small inputs, and more on the 1.1 GiB bug65649 run). There is no bottleneck to optimize, so no optimization code changed. The tests and the visual comparison above are the final revalidation.

Checklist

  • Commits include a Signed-off-by line
  • Final memory/speed pass completed after visual acceptance, or no conversion runtime impact is explained
  • PR scope contains one root cause
  • Remaining converter or harness deviations each reference an open issue

🤖 Generated with Claude Code

…dent

Word puts a list item's marker at the margin plus its w:ind w:left minus
its hanging span, and its lines at the margin plus w:left, even when that
lies left of the margin. paragraph_list_indent clamped the left indent
and the marker origin at zero, and when the origin clamped onto the
indent it returned no geometry at all, so Typst's default enum layout
took over. In docxide-pdf case97 (w:left="-100" w:hanging="5") Word
draws "1." at 66.775pt and the tabbed body at 80.525pt; main drew them
at 72pt and 88pt, and now draws them at 66.75pt and 80.55pt.

Word flow lists keep the sign; Typst's list indent accepts a negative
length. Table cells keep the clamp until they clip at the cell edge
(#2261), and slide lists keep theirs.

A corpus scan found that a paragraph style's w:ind still stands in for a
direct w:numPr level's indent (#2383). Hanging that wrong indent into
the margin would move such markers further from Word, so those
paragraphs keep main's geometry until #2383 is fixed.

Related: #2233
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yonghye Kwon <developer.0hye@gmail.com>
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