Compare commits

...

2 Commits

Author SHA1 Message Date
Brandon Thomas 5730bf1be7 R22: text box tests name the new insets field
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 08:53:45 -04:00
Brandon Thomas 7027853c10 R22: text boxes keep their own margins
Read lIns/tIns/rIns/bIns from a text box's wps:bodyPr (missing ones take
Word's 0.1"/0.05" defaults, hostile values clamped), lay the text out inside
them and write them back. Every text box had Word's default margins, so the
SARP form's zero-margin field labels sat 3.6 pt low.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 08:48:39 -04:00
11 changed files with 89 additions and 13 deletions

View File

@ -130,6 +130,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
## Progress log
| Date | What landed |
| 2026-10-12 | Text boxes keep their own margins between box and text (`lIns`/`tIns`/`rIns`/`bIns`), read, laid out and written back; they always had Word's defaults |
| 2026-10-11 | Hide White Space (#645): `view.hideWhiteSpace` (View › Show checkbox, or double-click the gap between pages) collapses margins, headers and footers between pages to a thin line in Print Layout; screen only, page count unchanged |
| 2026-10-11 | Fonts: Palatino Linotype and Book Antiqua fall back to Palatino (macOS) or TeX Gyre Pagella / P052 / URW Palladio (Linux) before generic serif (#670) |
|---|---|

View File

@ -274,12 +274,25 @@ pub struct TextBody {
/// continues in when it doesn't fit this one. The linked box shows the rest of this box's
/// story; its own blocks are unused. See [`Document::text_box_chains`].
pub next: Option<u32>,
/// The margins between the box and its text (left, top, right, bottom), in EMUs (12,700 to
/// the point); `None` is Word's default, 0.1" at the sides and 0.05" at top and bottom.
#[serde(skip_serializing_if = "Option::is_none")]
pub insets: Option<[u32; 4]>,
}
impl TextBody {
/// Word's default text box margins, EMUs (left, top, right, bottom).
pub const DEFAULT_INSETS: [u32; 4] = [91_440, 45_720, 91_440, 45_720];
/// Largest margin kept from a file, EMUs (22").
pub const MAX_INSET: u32 = 20_116_800;
pub fn is_default(&self) -> bool {
*self == TextBody::default()
}
/// The margins in points (left, top, right, bottom).
pub fn inset_pts(&self) -> [f32; 4] {
self.insets.unwrap_or(Self::DEFAULT_INSETS).map(|v| v.min(Self::MAX_INSET) as f32 / 12_700.0)
}
}
/// Most boxes one chain of linked text boxes runs through (hostile files).

View File

@ -1160,6 +1160,17 @@ impl Reader<'_> {
if let Some(a) = bp.attr("anchor") {
part.body.anchor = wordcraft_doc::props::valign_from_anchor(a);
}
// Margins between box and text (`lIns`…, EMUs), each defaulting to Word's.
let names = ["lIns", "tIns", "rIns", "bIns"];
if names.iter().any(|n| bp.attr(n).is_some()) {
let mut insets = wordcraft_doc::TextBody::DEFAULT_INSETS;
for (v, n) in insets.iter_mut().zip(names) {
if let Some(x) = bp.attr(n).and_then(|a| a.trim().parse::<i64>().ok()) {
*v = x.clamp(0, i64::from(wordcraft_doc::TextBody::MAX_INSET)) as u32;
}
}
part.body.insets = Some(insets);
}
}
let mut extra = ShapeExtra::default();
// Arrowheads: any end type but `none` (ECMA-376 §20.1.10.33) is drawn as a triangle.

View File

@ -1019,6 +1019,12 @@ impl Writer<'_> {
if body.anchor != wordcraft_doc::props::VAlign::Top {
attrs.push(("anchor", wordcraft_doc::props::anchor_ooxml(body.anchor)));
}
let insets = body.insets.map(|v| v.map(|x| x.to_string()));
if let Some(v) = &insets {
for (n, x) in ["lIns", "tIns", "rIns", "bIns"].into_iter().zip(v) {
attrs.push((n, x));
}
}
match extra.warp.as_ref().and_then(|t| t.sanitized()) {
Some(warp) => {
w.open("wps:bodyPr", &attrs);

View File

@ -1512,3 +1512,22 @@ fn vml_lines_and_rectangles() {
}
assert!(matches!(&objs[1], InlineObject::Shape { kind: ShapeKind::Rectangle, fill: Some(Rgb(255, 0, 0)), stroke: None, w: 30.0, h: 20.0, .. }));
}
/// A text box's margins (`lIns`, `tIns`, `rIns`, `bIns` on `wps:bodyPr`, EMUs) are read, missing
/// ones taking Word's defaults, and written back; hostile values are clamped.
#[test]
fn text_box_insets_read_and_round_trip() {
let boxed = |body_pr: &str| {
format!(
r#"<w:p><w:r><w:drawing><wp:anchor behindDoc="0"><wp:positionH relativeFrom="page"><wp:posOffset>0</wp:posOffset></wp:positionH><wp:positionV relativeFrom="paragraph"><wp:posOffset>0</wp:posOffset></wp:positionV><wp:extent cx="914400" cy="457200"/><wp:wrapNone/><wp:docPr id="1" name="Text Box 1"/><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><wps:wsp><wps:cNvSpPr txBox="1"/><wps:spPr><a:prstGeom prst="rect"/></wps:spPr><wps:txbx><w:txbxContent><w:p><w:r><w:t>boxed</w:t></w:r></w:p></w:txbxContent></wps:txbx>{body_pr}</wps:wsp></a:graphicData></a:graphic></wp:anchor></w:drawing></w:r></w:p>"#
)
};
let insets = |d: &Document| d.parts.values().find(|p| p.kind == wordcraft_doc::PartKind::TextBox).map(|p| p.body.insets);
let d = read_body(&boxed(r#"<wps:bodyPr lIns="0" tIns="0" rIns="12700"/>"#));
assert_eq!(insets(&d), Some(Some([0, 0, 12_700, 45_720])), "missing ones take the default");
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(insets(&again), insets(&d));
assert_eq!(insets(&read_body(&boxed("<wps:bodyPr/>"))), Some(None), "none given: Word's defaults");
let hostile = read_body(&boxed(r#"<wps:bodyPr lIns="-5" tIns="99999999999999" rIns="x"/>"#));
assert_eq!(insets(&hostile), Some(Some([0, wordcraft_doc::TextBody::MAX_INSET, 91_440, 45_720])));
}

View File

@ -53,9 +53,9 @@ fn text_box(d: &mut Document, text: &str, body: TextBody) -> InlineObject {
#[test]
fn direction_alignment_and_links_round_trip() {
let mut d = Document::from_text("a\nb\nc");
let a = text_box(&mut d, "Flows on", TextBody { vert: TextVert::Vert, anchor: VAlign::Center, next: None });
let a = text_box(&mut d, "Flows on", TextBody { vert: TextVert::Vert, anchor: VAlign::Center, next: None, insets: None });
let b = text_box(&mut d, "", TextBody::default());
let c = text_box(&mut d, "", TextBody { vert: TextVert::Stacked, anchor: VAlign::Bottom, next: None });
let c = text_box(&mut d, "", TextBody { vert: TextVert::Stacked, anchor: VAlign::Bottom, next: None, insets: None });
for (i, o) in [a, b, c].into_iter().enumerate() {
d.insert_object(&wordcraft_doc::Pos::body(i, 1), o, &Default::default()).unwrap();
}

View File

@ -924,8 +924,11 @@ fn clear_of_float_tables(mut r: Rect, f: &TableFloat, legacy: f32, text_left: f3
r
}
/// A text box's internal margins (Word's defaults: 0.1" left/right, 0.05" top/bottom).
/// A text box's default internal margins (Word's: 0.1" left/right, 0.05" top/bottom; a box's own
/// are `TextBody::inset_pts`).
#[cfg(test)]
const BOX_INSET_X: f32 = 7.2;
#[cfg(test)]
const BOX_INSET_Y: f32 = 3.6;
/// Keep what fits in a box `height` tall (items relative to its top): lines that don't fit are

View File

@ -133,3 +133,22 @@ fn hostile_link_chains_stay_bounded() {
assert_eq!(lines(&l, id).len(), 1, "box {id} shows its own text");
}
}
/// A text box's own margins (`lIns`, `tIns`… in its `wps:bodyPr`) move its text, measured in Word
/// for Windows (`plan/word` R22: the SARP form's boxes have none, and their labels sat 3.6 pt low).
#[test]
fn a_text_box_uses_its_own_margins() {
let text_at = |insets: Option<[u32; 4]>| {
let mut d = Document::from_text("x");
let id = text_box(&mut d, 0, "Inside", 144.0, 72.0, TextBody { insets, ..Default::default() });
let l = lay(&d);
let ((x, y, ..), r) = (lines(&l, id)[0], frame_of(&l, id));
(x - r.x, y - r.y)
};
let (x, y) = text_at(None);
assert!((x - BOX_INSET_X).abs() < 1.0 && (y - BOX_INSET_Y).abs() < 1.0, "Word's default margins: {x}, {y}");
let (x, y) = text_at(Some([0, 0, 0, 0]));
assert!(x.abs() < 1.0 && y.abs() < 1.0, "no margins: {x}, {y}");
let (x, y) = text_at(Some([254_000, 127_000, 0, 0]));
assert!((x - 20.0).abs() < 1.0 && (y - 10.0).abs() < 1.0, "20 pt and 10 pt: {x}, {y}");
}

View File

@ -14,7 +14,7 @@ use wordcraft_doc::props::{TextDirection, TextVert, VAlign};
use wordcraft_doc::{Block, Blocks, Path, StoryRef, TextBody};
use wordcraft_geom::Rect;
use crate::{BOX_INSET_X, BOX_INSET_Y, Ctx, Placed, fit_box, layout_box};
use crate::{Ctx, Placed, fit_box, layout_box};
/// Linked text boxes for one layout, worked out when a linked box is first laid out.
#[derive(Default)]
@ -55,11 +55,12 @@ pub(crate) fn shown_story(ctx: &mut Ctx, id: u32) -> u32 {
}
/// Line length and the depth lines stack in, for a `w` × `h` box whose text runs `vert`.
fn frame(vert: TextVert, w: f32, h: f32) -> (f32, f32) {
fn frame(body: &TextBody, w: f32, h: f32) -> (f32, f32) {
let (w, h) = (wordcraft_geom::finite(w), wordcraft_geom::finite(h));
let (along, across) = match vert.turn() {
TextDirection::Horizontal => (w - 2.0 * BOX_INSET_X, h - 2.0 * BOX_INSET_Y),
_ => (h - 2.0 * BOX_INSET_Y, w - 2.0 * BOX_INSET_X),
let [l, t, r, b] = body.inset_pts();
let (along, across) = match body.vert.turn() {
TextDirection::Horizontal => (w - l - r, h - t - b),
_ => (h - t - b, w - l - r),
};
(along.max(12.0), across)
}
@ -97,7 +98,7 @@ fn lay(ctx: &mut Ctx, id: u32, blocks: &Blocks, len: f32, vert: TextVert, depth:
/// share of a linked chain's, cut to what fits, aligned and turned as the box says.
pub(crate) fn box_items(ctx: &mut Ctx, id: u32, rect: Rect, depth: usize) -> Vec<Placed> {
let body = ctx.doc.parts.get(&id).map(|p| p.body).unwrap_or_default();
let (len, across) = frame(body.vert, rect.w, rect.h);
let (len, across) = frame(&body, rect.w, rect.h);
let items = match chain_share(ctx, id, depth) {
Some(items) => items,
None => {
@ -124,11 +125,12 @@ fn place(mut items: Vec<Placed>, body: &TextBody, rect: Rect, across: f32) -> Ve
VAlign::Bottom => (across - used).max(0.0),
};
let turn = body.vert.turn();
let [l, t, r, b] = body.inset_pts();
let (ox, oy) = match turn {
TextDirection::Horizontal => (rect.x + BOX_INSET_X, rect.y + BOX_INSET_Y + off),
TextDirection::Horizontal => (rect.x + l, rect.y + t + off),
// Top-to-bottom lines stack from the right edge, bottom-to-top ones from the left.
TextDirection::Down => (rect.right() - BOX_INSET_X - off, rect.y + BOX_INSET_Y),
TextDirection::Up => (rect.x + BOX_INSET_X + off, rect.bottom() - BOX_INSET_Y),
TextDirection::Down => (rect.right() - r - off, rect.y + t),
TextDirection::Up => (rect.x + l + off, rect.bottom() - b),
};
for it in &mut items {
it.turn(turn, ox, oy);
@ -204,7 +206,7 @@ fn flow(ctx: &mut Ctx, chain: &[u32], depth: usize) -> Vec<Vec<Placed>> {
};
let body = ctx.doc.parts.get(id).map(|p| p.body).unwrap_or_default();
let (w, h) = sizes.get(id).copied().unwrap_or((0.0, 0.0));
let (len, across) = frame(body.vert, w, h);
let (len, across) = frame(&body, w, h);
let key = (len.to_bits(), body.vert);
let items = match layouts.iter().find(|(k, _)| *k == key) {
Some((_, items)) => items.clone(),

View File

@ -156,6 +156,7 @@ raise readiness estimates. No Word-derived files are fixtures.
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | trivial | DOCX: text box margins (`wps:bodyPr` `lIns`/`tIns`/`rIns`/`bIns`) read and written |
| 2026-10-11 | trivial | `.doc`: inline and floating pictures (BLIP store, PNG/JPEG/DIB/TIFF/EMF/WMF), basic shapes, groups and text boxes read from OfficeArt drawings (#551); fidelity ~55% → ~60% |
| 2026-10-11 | trivial | Word XML Document (Flat OPC) read and written, Word 2003 XML read (#548) |
| 2026-10-11 | trivial | #534: DOCX indents in characters read and written; `.doc` first-line indents (`sprmPDxaLeft1`, its two-byte operand, and `sprmPDxaLeft1_80`), indents in characters (`sprmPDxc*`) and exact/at-least line spacing (LSPD) read correctly |

View File

@ -75,6 +75,7 @@ font can't match Word's metrics.
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | trivial | Text boxes lay out their text inside their own margins (Word's 0.1"/0.05" when the file gives none) |
| 2026-10-11 | trivial | Chinese documents (#534): indents in characters, a page-anchored floating table after a full page moves to the next page, no blank page from a page break at the bottom of a full page |
| 2026-10-11 | trivial | East Asian list number formats (一、二、三, 第一章, ①); list labels whose characters come from two fonts draw each part with its own font (the digits of "1、" no longer show as other letters); a list level without indents leaves the label at the paragraph edge (#534) |
| 2026-10-11 | trivial | Vertical sections: `w:textDirection` in `w:sectPr`, upright East Asian glyphs (#491) |