Skip to content

fix(docx): hang a body paragraph with a negative indent into the margin - #2382

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

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

Conversation

@developer0hye

@developer0hye developer0hye commented Oct 20, 2026 •

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

First milestone of #2233: a body paragraph with a negative w:ind w:left or w:right now hangs into the margin, as Word does.

paragraph_indent_pt clamped both indents to zero, on the stated grounds that "a Typst inset cannot be negative". That is false: #block(inset: ...) takes negative values, and picture paragraphs have emitted signed insets since #2260. The flow paragraph now emits the signed inset. Its lines start at margin + left, the first line also adds its signed first-line offset, and a negative right indent ends the line past the right margin.

Related issue

Related: #2233, #2261, #1874, #2243.

Testing

  • cargo test --locked --workspace --profile ci: 4,510 passed, 0 failed, 15 ignored.
  • cargo fmt --all -- --check and cargo clippy --locked --workspace --all-targets --profile ci -- -D warnings: clean (local clippy 1.97).
  • New compiled-layout tests in typst_gen_paragraph_tests.rs; both failed on main's code before the fix:
    • a_negative_paragraph_indent_hangs_its_lines_into_the_margin lays out a wrapping paragraph on A4 with 72pt margins and reads every line's start. Expected starts: left −36 gives 36/36; left −36 with firstLine 40 gives 76/36; left −36 with hanging 18 gives 18/36; the positive control left 20 gives 92/92. A right-aligned line moves +18pt for right −18 and −18pt for right 18. On main the first case gave 72 for every line.
    • a_paragraph_hanging_into_the_margin_starts_where_word_starts_it uses the tracked, unmodified docxide-pdf fixtures spaced_section_mark.docx (paragraph-offset-frame-no-frame) and offset_text_frame.docx (paragraph-offset-frame-immediate). "About providing information" must start at Word's 92.05pt in the body and 72.28pt in its text-anchored frame, within 0.25pt. Main gave 163.05pt.
  • Corpus scan. I built release CLIs into run-owned targets: main 5ea31ded and this branch. The inputs were every DOCX with a negative w:ind in any part: 151 files from the fixtures-v1 bulk corpus and docxide-pdf fe7b8bea cases/ and scraped/, plus 40 random controls. No conversion failed. The 40 controls and every table-cell case (118–120, 162–175, 193–199) have identical text geometry, and so does the list case97. 29 files changed. Each of the 16 changed docxide scraped/ documents was checked against its Word reference:
    • Moved toward Word, examples given as main → this PR (Word):
      • polish_council_session_notice 70.85 → 65.20 (65.03)
      • russian_construction_org_council_minutes 85.05 → 58.05 (58.02)
      • eco_int_agriculture_registration 231.14 → 217.64 (217.60)
      • ukrainian_municipal_heating_resolution 130.93 → 120.36 (120.30)
      • mongolian_human_rights_law 217.20 → 226.20 (226.10)
      • radiographer_interventional_job_desc 207.08 → 204.71 (204.85)
      • learning_cultures_dissertation's check marks 382–503 → 320.11 (319.13)
    • feminist_voice_dissertation (left −284) now keeps "Date……" on the Chairperson line at 396pt, as Word does (396.17); main wrapped it to the next line.
    • alfies_arc_adult_safeguarding_policy (justified, right −46) now breaks its second line where Word does.
    • Moved toward Word but not all the way: chiseldon_parish_planning_agenda 106.73 → 102.23 (96.80), and arizona_physical_education_standards 76.04 → 72.34 (26.50). Of its lines that re-wrap, 22 of this PR's match a Word line, against 17 of main's.
    • estonian_community_development_grant, japanese_medical_consultation_form, tokyo_welfare_infrastructure_briefing and polish_building_procurement_spec move by 0.1–0.8pt, following their small signed indents.
    • Bulk files without a Word export move by exactly their indents: LibreOffice's indents.docx lines labelled "Negative 2cm" move −56.7pt, and testOuterShdw's justified w:right="-360" lines widen by 18pt. In tdf116410 (Strict conformance) the tab after "A" now falls to the default stop at the margin, because the style's explicit 85.05pt stop is never read (DOCX: a Strict-conformance package's styles part is never read, so its theme face and stated w:sz are lost #2243); its −14.15pt indent arrives as −14pt (Tracker: sub-materiality visual deviations (revisit in bulk) #1874).

Visual impact

  • No rendered PDF change
  • Rendered PDF change or visual evidence added
  • Reason: A body paragraph with a negative indent now starts left of the margin where Word starts it.

Visual audit

  • Issue: DOCX: a negative w:ind w:left is clamped to zero, so a paragraph that hangs into the margin starts 71pt right of Word #2233
  • Fixture: tests/fixtures/docx/docxide/spaced_section_mark.docx (unmodified docxide-pdf cases/paragraph-offset-frame-no-frame/input.docx at fe7b8beafa7612cc89ad62a7d27b2c6141c4d6ed, Apache-2.0; GT is that case's reference.pdf, Creator: Microsoft Word, check_gt_integrity.py --source clean)
  • Page(s): 1
  • Renderer and DPI: pdftoppm via compare_render.py, 150 DPI
  • Evidence mode: fix
  • Baseline failure report: None
  • Layout audit report: assets/bugfixes/issue-2233/layout-audit.json
  • Render cluster reports: assets/bugfixes/issue-2233/render-clusters-page-1.json
  • Reference exporter differences: None
  • Fine-detail threshold: 0.5pt
  • Layout audit page count: Pass
  • Layout audit text flow: Pass
  • Layout audit visible fills: Pass
  • Layout audit rectangle geometry: Pass
  • Layout audit large shifts: Pass
  • Layout audit fine shifts: Pass
  • New follow-up issues found in this audit: None
  • Model vision findings: I inspected the full 150-DPI GT, before and after pages, the 5% diff, a 300-DPI crop of the text block on all three, and 600-DPI GT/after/diff crops of each of the 12 clusters.
    • Before: "About providing information" starts 71pt right of Word, under the end of "Recipient". After: it starts 6.52pt right of "Recipient", as in Word (6.53pt), and its baseline is unchanged.
    • Every cluster is the same glyphs at the same positions with outline-only edges. Word embeds Times New Roman, and this host paints the metric-compatible Liberation Serif; the crossbar of "e" and the serifs of "A" and "g" differ in shape only.
    • Line starts against Word: "Recipient" +0.10pt, "About providing information" +0.10pt, "Dear recipient" +0.03pt; baselines within 0.09pt. These are recorded on Tracker: sub-materiality visual deviations (revisit in bulk) #1874.
    • The page has no rules, fills or shapes. All text is regular weight, upright and not underlined, in both.
  • GT: assets/bugfixes/issue-2233/gt.jpg
  • Before: assets/bugfixes/issue-2233/before.jpg
  • After: assets/bugfixes/issue-2233/after.jpg
  • Native: None
  • Compare: None

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: all three text lines are present; the page has no other elements.
Position/size Fixed: "About providing information" starts at 92.15pt against Word's 92.05pt (main 163.05pt). The 0.10pt residual and the other lines' sub-point offsets are recorded on #1874.
Rotation/flip No deviation observed.
Fill No deviation observed: the page has no fills.
Stroke/border No deviation observed: the page has no rules or borders.
Shape outline geometry No deviation observed: the page has no shapes.
Text content Matches GT: compare_text_layer.py finds normalized content identical and no codepoint class difference.
Font family/weight/style Matches GT: Times New Roman is painted as the metric-compatible Liberation Serif; all text is regular and upright in both.
Text color Matches GT: black text on white.
Alignment Fixed: the hanging paragraph's first line now starts at margin − 70.9pt + 78pt, as in Word.
Line/paragraph spacing Matches GT: baselines within 0.09pt of Word's.
Clipping/overflow No deviation observed.

Final performance pass

  • Status: Complete
  • Measurements: elapsed time and peak RSS (/usr/bin/time -f "%e %M"), median of 5 runs after one warm-up, per input and binary.
    • Release CLIs (cargo build --locked --release -p office2pdf-cli) built into separate run-owned targets: main 5ea31ded (base) and this branch (cand).
    • Host: Intel i5-10400, 12 threads, Linux 7.0.0; Liberation fonts and no Microsoft core fonts; warm page cache.
    • Inputs: the evidence file spaced_section_mark.docx; offset_text_frame.docx; POI's bug65649.docx (177 pages, 32 negative indents); LibreOffice's position-and-rotation.docx (control, no negative indent).
    • Median elapsed / peak RSS, base → cand:
      • spaced_section_mark: 0.270s / 42.8 MiB → 0.260s / 40.7 MiB
      • offset_text_frame: 0.270s / 42.3 MiB → 0.270s / 41.0 MiB
      • bug65649: 4.320s / 1152.2 MiB → 4.300s / 1133.9 MiB
      • position-and-rotation: 0.400s / 45.6 MiB → 0.400s / 43.7 MiB
  • Outcome and revalidation: no measurable change, so no optimization is warranted.
    • Elapsed time is equal within 20ms.
    • The lower candidate RSS (about 2 MiB on the small inputs) is within the build-to-build variation of byte-identical builds (up to 2.6 MiB in fix(docx): pass a dropped section mark's w:after on to the next continuous section #2381's measurement), not a gain.
    • The change removes two max calls and adds a clamp at the cell call site; it adds no work per paragraph.
    • No optimization code was changed, so the visual comparison and test suite 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

Word lets a paragraph's w:ind w:left or w:right be negative: its lines
start at the margin plus the signed left indent (plus the first-line
offset on the first line), and a negative right indent ends them past
the right margin. paragraph_indent_pt clamped both to zero on the claim
that a Typst inset cannot be negative, which is false: block insets
accept negative values, as picture paragraphs already rely on.

The flow paragraph now emits the signed inset. In docxide-pdf's
paragraph-offset-frame-no-frame, "About providing information"
(left -70.9pt, firstLine +78pt) starts at 92.15pt where Word draws it
at 92.05pt; main drew it at 163.05pt. Its framed sibling moves to
72.4pt against Word's 72.28pt.

Table cells keep the clamp until they clip their content at the cell
edge (#2261): Word hides the part of a hanging cell paragraph that
crosses the edge, so painting it into the next cell would not match.

Related: #2233
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yonghye Kwon <developer.0hye@gmail.com>
@developer0hye
developer0hye merged commit ca19443 into main Oct 20, 2026
26 of 27 checks passed
@developer0hye
developer0hye deleted the fix/docx-negative-paragraph-indent branch October 20, 2026 10:27
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