Layout: a picture lowered past its height lowers its line as far, as in Word (#5796)

* Layout: a picture lowered past its height lowers its line as far, as in Word

A picture lowered further than its own height had its lowering cut at that
height. Word keeps its top on the baseline but lowers the line as far as the
position says: a 6 pt picture lowered 30 pt makes a 30 pt line. Exact spacing
keeps the line's baseline. Closes the gap noted with #3984: all 156 measured
lines now match Word.

* Lowered pictures: an exact-spacing test; gaps and progress log

An exact-spacing line keeps its baseline when a picture is lowered past its
height (the test fails without the cap). Gap 4 and the progress log note the
change.

---------

Co-authored-by: Daniele Mori <Dinasty@hotmail.it>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
This commit is contained in:
Morzilla
2026-11-08 02:54:47 +01:00
committed by GitHub
co-authored by Daniele Mori Martin Ball
parent cac121fd89
commit bc0f691c13
8 changed files with 53 additions and 14 deletions
+2
View File
@@ -152,6 +152,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
- 2026-11-05: ODT table rows kept from breaking across pages stay so (gap 19): they came back from a save free to break.
- 2026-11-05: ODT: a watermark added, changed or removed, and Different Odd & Even Pages turned on or off, in a file read from ODT are saved.
- 2026-11-05: ODT pictures offered in several formats show and survive a save (gap 19): LibreOffice's letter templates lost theirs.
- 2026-11-07: An in-line picture lowered past its own height (`w:position`) lowers its line as far, as in Word; all 156 synthetic lines measured for #3984 match Word (#5796). Exact spacing keeps the line's baseline.
- 2026-11-06: Windows print submissions run in separate helper processes (#5710). A helper acknowledges cancellation before it stops, so a driver stalled in a cancelled job cannot hold another job’s provider lock. A private temporary PDF and bounded settings messages cross the process boundary; normal shutdown reaps helpers and removes their inputs. The helper erases its plaintext inputs before driver work; a hard app crash during startup can still leave private temporary files. Jobs already sending still complete. Readiness estimates unchanged.
- 2026-11-06: File › Print: before sending starts, Cancel printing stops a waiting job, frees the Print page and keeps the settings; a successfully cancelled job is never sent. Cancellation remains available after the last document closes. Quitting cancels waiting jobs and joins jobs already sending (#5710). On Windows, winprint repeats the DEVMODE conversion after sending starts. A stall during that repeat prevents cancellation and can keep shutdown waiting, even before a native job exists. Readiness estimates unchanged.
- 2026-11-06: File › Print on Windows no longer waits on printer drivers to list printers (#5710). The list used to wait until each printer's driver said whether it prints on both sides, and a networked printer asleep in power save could keep it empty with Refresh unavailable; now discovery asks no driver (the Windows spooler still supplies the list), and printing still checks the job's options with the driver. The reporter's sleeping printer has not been retested yet. Readiness estimates unchanged.
@@ -824,6 +825,7 @@ MATH glyphs that reference the same construction now share its immutable variant
|---|---|---|
| 2026-11-07 | trivial | Page Setup also bounds the unfocused margin display after a long numeric paste; the accepted value is retained (#5650). |
| 2026-11-05 | trivial | Page Setup's margin, paper-size and document-grid number boxes scroll long text inside the field instead of enlarging the dialog (#5650). Readiness estimates unchanged. |
| 2026-11-07 | trivial | Progress log: An in-line picture lowered past its own height (`w:position`) lowers its line as far, as in Word; all 156 synthetic lines measured for #3984 match Word (#5796). |
| 2026-11-06 | trivial | Print helpers remove loaded PDF/settings files before driver work, tolerate prior removal and report cleanup errors; a held-driver regression checks the files are already absent (#5710) |
| 2026-11-06 | trivial | Windows print submissions isolated per helper process; cancelled driver work cannot block a retry (#5710) |
| 2026-11-06 | trivial | Progress log: print cancellation before sending, including without a document, and the Windows repeated-conversion limitation (#5710) |
+1 -1
View File
@@ -656,7 +656,7 @@ fn inline_objects(ctx: &LineContext<'_, '_, '_>, out: &mut Vec<Draw>) {
Some(o @ InlineObject::Field { .. }) => {
if let Some(wordcraft_doc::legacy_form::LegacyForm::CheckBox { checked, .. }) = o.legacy_form() {
let color = pl.styles.get(c.style as usize).map_or(Rgb::BLACK, |st| text_color(&st.rc.color, None));
let frame = Rect::new(cx, base - c.obj_h, c.adv, c.obj_h + c.obj_d);
let frame = Rect::new(cx, base - c.obj_h.max(0.0), c.adv, c.obj_h + c.obj_d);
check_box(frame, checked, color, alpha, out);
}
}
+2 -1
View File
@@ -491,7 +491,8 @@ pub(crate) fn effects_in(effects: wordcraft_doc::effects::ShapeEffects, spin: Sp
pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f32, (obj_h, obj_d): (f32, f32), line: f32) -> Rect {
let frame = obj.and_then(InlineObject::frame);
let [l, t, r, b] = frame.map_or([0.0; 4], |(_, _, float)| float.effect_extent());
let room = Rect::new(cx + l, base - obj_h + t, (adv - l - r).max(0.0), (obj_h + obj_d - t - b).max(0.0));
// Lowered further than its height, it is drawn with its top on the baseline (Word).
let room = Rect::new(cx + l, base - obj_h.max(0.0) + t, (adv - l - r).max(0.0), (obj_h + obj_d - t - b).max(0.0));
// A horizontal rule is as wide as its share of the paragraph's width, whatever narrows its
// line, and is placed by its alignment in what the line has left: it runs past the margin
// beside a float or a first-line indent, as in Word (see `ParaLayout::rule_room`).
+4 -2
View File
@@ -438,8 +438,10 @@ impl<'e> Breaker<'e> {
if matches!(rp.line_spacing, LineSpacing::Exactly(_)) { (st.ascent, st.descent) } else { (c.obj_h, c.obj_d) }
} else {
ex.obj_asc = ex.obj_asc.max(c.obj_h);
// A lowered picture reaches below the baseline.
ex.obj_desc = ex.obj_desc.max(c.obj_d);
// A lowered picture reaches below the baseline, with exact spacing no further
// than its height (the line's box is fixed; its baseline stays where it was).
let reach = if matches!(rp.line_spacing, LineSpacing::Exactly(_)) { c.obj_d.min(c.obj_h + c.obj_d) } else { c.obj_d };
ex.obj_desc = ex.obj_desc.max(reach);
(st.ascent, st.descent)
}
} else {
+6 -5
View File
@@ -338,7 +338,8 @@ impl<'a> Shaped<'a> {
/// A picture, shape or text box: inline, as wide and high as it is; a floating one takes no room.
/// An inline drawing: it stands on the baseline, raised or lowered by its run's position
/// (`raise`, points, as Word moves an inline picture with `w:position`). Its cluster reaches
/// `obj_h` above the baseline and `obj_d` below it (negative when raised clear of it).
/// `obj_h` above the baseline and `obj_d` below it (negative when raised clear of it; `obj_h`
/// negative when lowered further than its height, drawn with its top on the baseline).
fn drawing(&mut self, cx: &Cx, (k, o): (usize, &InlineObject), slot: Slot, raise: f32) {
let env = cx.env;
// A hidden one takes no room, and with no height its line doesn't draw it.
@@ -368,10 +369,10 @@ impl<'a> Shaped<'a> {
let [el, et, er, eb] = float.effect_extent();
let (px, py) = float.spin_pad(w * s, h * s);
let tall = h * s + et + eb + 2.0 * py;
// Lowered at most until its top is on the baseline. The bound is made finite and
// not above `clamp`'s top whatever the extents give (`clamp` panics otherwise).
let floor = if tall.is_finite() { -tall.max(0.0) } else { 0.0 };
let raise = if raise.is_finite() { raise.clamp(floor, 1584.0) } else { 0.0 };
// Lowered further than its height, its top stays on the baseline while the line
// reaches as far down as the lowering (Word, measured: a 6 pt picture lowered 30 pt
// makes a 30 pt line); `obj_h` is then negative. Word's positions run to 1584 pt.
let raise = if raise.is_finite() { raise.clamp(-1584.0, 1584.0) } else { 0.0 };
self.b.clusters.push(slot.cluster(ClKind::Object(k), w * s + el + er + 2.0 * px, (tall + raise, -raise)));
} else {
self.push(slot, ClKind::Object(k), 0.0, 0.0);
+34 -3
View File
@@ -5598,8 +5598,13 @@ fn an_inline_picture_moves_with_its_runs_position() {
assert!(line.top + line.height - line.baseline >= 30.0 - 0.01, "{line:?}");
let r = display::inline_rect(Some(&picture(40.0, 30.0, Default::default())), 0.0, 100.0, c.adv, (c.obj_h, c.obj_d), c.adv);
assert_eq!((r.y, r.h), (100.0, 30.0));
// Lowered further than that: still only to its top.
assert_eq!(line_of(-500.0).0.obj_d, 30.0);
// Lowered further than that: the line reaches as far down, the picture's top staying on the
// baseline (Word).
let (c, line, _) = line_of(-500.0);
assert_eq!((c.obj_h, c.obj_d), (-470.0, 500.0));
assert!(line.top + line.height - line.baseline >= 500.0 - 0.01, "{line:?}");
let r = display::inline_rect(Some(&picture(40.0, 30.0, Default::default())), 0.0, 100.0, c.adv, (c.obj_h, c.obj_d), c.adv);
assert_eq!((r.y, r.h), (100.0, 30.0));
// Raised: it stands clear of the baseline, as tall as before.
let (c, _, _) = line_of(6.0);
assert_eq!((c.obj_h, c.obj_d), (36.0, -6.0));
@@ -13694,7 +13699,7 @@ fn lowered_pictures_and_check_boxes_take_words_line_heights() {
let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };
let c = para.clusters.iter().find(|c| matches!(c.kind, para::ClKind::Object(_))).unwrap();
let (line, st) = (&para.lines[0], &para.styles[c.style as usize]);
(line.height, line.baseline - c.obj_h - line.top, st.ascent + st.descent)
(line.height, line.baseline - c.obj_h.max(0.0) - line.top, st.ascent + st.descent)
};
for m in [1.0, 1.15, 1.5] {
let share = |font: f32| (m - 1.0) * font;
@@ -13711,10 +13716,36 @@ fn lowered_pictures_and_check_boxes_take_words_line_heights() {
let (h, top, font) = line_of("", m, 6.0, raise);
assert!((h - font * m).abs() < 0.01 && (top - (font - reach)).abs() < 0.01, "{raise} at {m}: {h}, {top}");
}
// Lowered further than its height: as far down as it's lowered, the font's height at least,
// its top on the baseline (Word: a 6 pt picture lowered 10 pt takes the font's 11.5 pt,
// lowered 30 pt 30 pt; beside text, the text's ascent and the 30 pt).
let (h, top, font) = line_of("", m, 6.0, -10.0);
assert!((h - font * m).abs() < 0.01 && (top - (font - 10.0)).abs() < 0.01, "6 lowered 10 at {m}: {h}, {top}");
let (h, top, font) = line_of("", m, 6.0, -30.0);
assert!((h - 30.0 - share(font)).abs() < 0.01 && top.abs() < 0.01, "6 lowered 30 at {m}: {h}, {top}");
let (h, top, font) = line_of("x", m, 6.0, -30.0);
let asc = h - 30.0 - share(font);
assert!(asc > 0.0 && asc < font && (top - asc).abs() < 0.01, "6 lowered 30 beside text at {m}: {h}, {top}");
// Beside text, lowered: the text's ascent is no matter, the picture reaching further.
let (h, top, font) = line_of("x", m, 30.0, -10.0);
assert!((h - 30.0 - share(font)).abs() < 0.01 && top.abs() < 0.01, "beside text at {m}: {h}, {top}");
}
// Exact spacing keeps the line's box and its baseline: a picture lowered past its height sits
// where one lowered by its height does.
let exact = |raise: f32| {
let mut d = Document::from_text("x");
let props = wordcraft_doc::CharProps { position: Some(raise), ..Default::default() };
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
p.props.line_spacing = Some(LineSpacing::Exactly(20.0));
let at = p.len();
p.insert_object(at, picture(40.0, 6.0, Default::default()), &props).unwrap();
let l = lay(&d);
let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };
(para.lines[0].height, para.lines[0].baseline - para.lines[0].top)
};
let (h, base) = exact(-6.0);
assert!((h - 20.0).abs() < 0.01, "{h}");
assert_eq!(exact(-30.0), (h, base));
// A check box sizes its line as text of its size, alone or beside text.
let box_line = |lead: &str, m: f32, size: u32| {
let mut d = Document::from_text(lead);
+2 -1
View File
@@ -80,7 +80,7 @@ Page drawing indexes text-box warp lookups by paragraph and story for the curren
|---|---|---|---|---|---|---|
| 1 | **DOCX never tested on real-world files.** No corpus of files from Word, Google Docs, LibreOffice and Pages has been opened, rendered and round-tripped and compared with Word page by page | B | No corpus in the repo; `crates/docx/tests` are synthetic fixtures (including omitted/empty paragraph defaults and imported TOC range preservation); "DOCX fidelity corpus: not started" since 2026-10-06. Field reports fixed one by one: a cover page's theme-coloured shapes (#611), freeforms inside groups and text-box margins and Resize to fit (#743), shape gradients and transparency (#1500), cover-page positions relative to the page and freeforms drawn back to their start (#743), Word's text watermark in headers, read and written (#1290), VML freeform rule lines in headers, and `flowChartOr` drawn with its spokes (#1644); explicit DrawingML outline dashes and flat/square/round caps preserved; paragraph-relative header/footer/cell/text-box float origins corrected (cu20we, synthetic regressions) | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-parity.md) |
| 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170, open), a crash on macOS 12.7.6 Intel (#979, open) (lag on GPUs without a DirectX 12 driver fixed, #316; 32-bit Windows exited at launch because DirectX 12 graphics pipeline creation failed, now OpenGL), freezes (#59 Thai fonts and #31 font selector, both open; #77 closed), broken shape tool (#142, open); the Windows 11 no-write-without-admin (#218) and unmovable images (#82) reports are closed (pictures and shapes in Behind Text drag through the text over them, #1313); memory growth on every font change fixed (#848: the font list no longer loads each family it previews) | B | 197 open issues (2026-10-12), among them the crash and freeze reports #170, #979, #59 and #31 | Anyone hit loses work or trust; the never-crash standard covers panics, not hangs or GPU driver faults | 20–30 | [target-app-parity.md](target-app-parity.md) (Stability) |
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | SECTIONPAGES now counts shared continuous-section pages in body and header/footer fields; legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; non-overlapping body floating tables clear header tables; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; centred numbered heading labels after leading page breaks fixed (#2964); Arial Unicode MS no longer selects monospace substitutes; a slash opening a word travels whole to the next line (#2963) A right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, so a TOC entry's page number keeps its leader (#1840). A floating object anchored on a later page of a long paragraph is drawn on that page, also when footnotes or widow control move its line on (#4250). Fonts the document uses that aren't installed are flagged in the font box and status bar, except those drawn in a metric-compatible substitute (Carlito for Calibri, Liberation Sans for Arial) and symbol-font text (Symbol, Wingdings, Webdings), so the user knows when line breaks can differ from where the document was made; `font.missing` lists the substituted fonts in the text the current view lays out (capped, for hostile documents), marking the metric-compatible ones `metricCompatible`. | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | In-line pictures raised or lowered (`w:position`) set their line's height as in Word, past their own height too (#3984, #5796). SECTIONPAGES now counts shared continuous-section pages in body and header/footer fields; legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; non-overlapping body floating tables clear header tables; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; centred numbered heading labels after leading page breaks fixed (#2964); Arial Unicode MS no longer selects monospace substitutes; a slash opening a word travels whole to the next line (#2963) A right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, so a TOC entry's page number keeps its leader (#1840). A floating object anchored on a later page of a long paragraph is drawn on that page, also when footnotes or widow control move its line on (#4250). Fonts the document uses that aren't installed are flagged in the font box and status bar, except those drawn in a metric-compatible substitute (Carlito for Calibri, Liberation Sans for Arial) and symbol-font text (Symbol, Wingdings, Webdings), so the user knows when line breaks can differ from where the document was made; `font.missing` lists the substituted fonts in the text the current view lays out (capped, for hostile documents), marking the metric-compatible ones `metricCompatible`. | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | SECTIONPAGES now counts shared continuous-section pages in body and header/footer fields; legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; non-overlapping body floating tables clear header tables; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; centred numbered heading labels after leading page breaks fixed (#2964); Arial Unicode MS no longer selects monospace substitutes; a slash opening a word travels whole to the next line (#2963) A right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, so a TOC entry's page number keeps its leader (#1840). A floating object anchored on a later page of a long paragraph is drawn on that page, also when footnotes or widow control move its line on (#4250); one anchored right after a page break, before text, is drawn on the new page (#4262). Fonts the document uses that aren't installed are flagged in the font box and status bar, except those drawn in a metric-compatible substitute (Carlito for Calibri, Liberation Sans for Arial) and symbol-font text (Symbol, Wingdings, Webdings), so the user knows when line breaks can differ from where the document was made; `font.missing` lists the substituted fonts in the text the current view lays out (capped, for hostile documents), marking the metric-compatible ones `metricCompatible`. | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
| 2 | **Word's native ink is not drawn from its strokes**; OLE objects show only their picture | B | Every native ink form is preserved: drawing-level ink with its optional picture fallback, direct run-level content parts, and ink members of groups and drawing canvases (kept in their place among the members, drawn from their fallback picture). Drawing canvases are read and written (#344); group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Pen notes from Word show only as their picture and can't be edited; OLE objects can't be opened | 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) |
| 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; Track Changes Options has the Moves rows (Moved from / Moved to mark and colour) and `w:doNotTrackMoves` (Track moves off: a cut and paste is a deletion and an insertion) round-trips; still missing: drag-and-drop moves (no text drag yet). Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes (table, row, cell and section property changes now record in WordCraft too, #41 follow-up). Opaque custom XML backing stores now survive saves; binding evaluation remains absent. Replace All now checks content-control locks before editing any match; a refused batch preserves the document and undo history. | B | List-label pitch corrected in synthetic metric controls; Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) |
@@ -188,6 +188,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text
| 2026-11-05 | trivial | Page Setup's margin, paper-size and document-grid number boxes scroll long text inside the field instead of enlarging the dialog (#5650). Readiness estimates unchanged. |
| 2026-11-04 | trivial | Field argument import and page-format-aware layout/cache follow-ups; no ranked gap changed |
| 2026-11-03 | trivial | Field argument parsing follows Word quoted backslash escapes; body PAGE formatting follows the active section in paragraphs and tables; no ranked gap changed |
| 2026-11-07 | trivial | Pagination gap #4: An in-line picture lowered past its own height (`w:position`) lowers its line as far, as in Word; all 156 synthetic lines measured for #3984 match Word (#5796). |
| 2026-11-06 | trivial | Print helpers remove loaded PDF/settings files before driver work, tolerate prior removal and report cleanup errors; a held-driver regression checks the files are already absent (#5710) |
| 2026-11-06 | trivial | Windows print submissions use a separate helper per job, preserving provider serialization inside the helper. An acknowledged cancellation stops that helper, so a stalled cancelled driver cannot block a later job; normal shutdown reaps helpers and removes temporary PDFs. The helper erases its plaintext inputs before driver work; a hard app crash during startup can still leave private temporary files. Startup requires an explicit start grant; after sending begins, jobs complete (#5710) |
| 2026-11-06 | trivial | Gap 6: File › Print: before sending starts, Cancel printing (`ui.printCancel`) stops a waiting job and frees the Print page while keeping its settings, including after the last document closes; a successfully cancelled job is never sent. Quitting cancels waiting jobs and joins jobs already sending (#5710). On Windows, winprint repeats the DEVMODE conversion after sending starts. A stall during that repeat prevents cancellation and can keep shutdown waiting, even before a native job exists. No ranked gap changed; readiness estimates unchanged |
File diff suppressed because one or more lines are too long