mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-26 11:27:10 -06:00
- Model: `SectionProps::doc_grid` (`w:docGrid` type, line pitch, charSpace) and the paragraph `w:snapToGrid` flag; DOCX read and write (ECMA-376 §17.6.5). - Layout: with a line grid, lines of snapping paragraphs take whole line pitches with their text centred; with a character grid, East Asian characters take whole cells of the character pitch (all text with snapToChars), Latin text stays proportional. Hostile pitches and charSpace are clamped. Headers and footers don't snap. - Command `layout.documentGrid` (type, linesPerPage, charsPerLine, linePitch, charSpace), one undo step, section scope; catalog row under Layout › Page Setup. - Page Setup dialog: Document Grid section; View › Gridlines draws the document grid of a section that has one. UI strings in every catalog (incl. et, sr, sr-latn). - Tests: DOCX round trip, line pitch and lines per page, character pitch placement, command with clamping and undo. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
94 lines
3.8 KiB
Rust
94 lines
3.8 KiB
Rust
//! Document grid (`w:docGrid`): line pitch and character pitch (#391). Assertions are relations
|
|
//! to the pitch, never font metrics (fonts differ between machines; CJK glyphs may be missing).
|
|
|
|
use super::*;
|
|
use wordcraft_doc::props::LineSpacing;
|
|
use wordcraft_doc::section::{DocGrid, DocGridType};
|
|
|
|
fn lay(doc: &Document) -> DocLayout {
|
|
layout(doc, &mut LayoutCache::new(), &LayoutOptions::default())
|
|
}
|
|
|
|
fn with_grid(text: &str, kind: DocGridType, char_space: i32) -> Document {
|
|
let mut d = Document::from_text(text);
|
|
for b in d.body.iter_mut() {
|
|
if let Block::Para(p) = std::sync::Arc::make_mut(b) {
|
|
p.props.space_before = Some(0.0);
|
|
p.props.space_after = Some(0.0);
|
|
p.props.line_spacing = Some(LineSpacing::Multiple(1.0));
|
|
}
|
|
}
|
|
d.last_section.doc_grid = Some(DocGrid { kind, line_pitch: 18.0, char_space });
|
|
d
|
|
}
|
|
|
|
/// (first-page lines, every line height) of a layout.
|
|
fn lines(l: &DocLayout) -> (usize, Vec<f32>) {
|
|
let mut first = 0;
|
|
let mut hs = Vec::new();
|
|
for (pi, p) in l.pages.iter().enumerate() {
|
|
for it in &p.items {
|
|
if let Placed::Lines { para, l0, l1, .. } = it {
|
|
if pi == 0 {
|
|
first += l1 - l0;
|
|
}
|
|
hs.extend(para.lines[*l0..*l1].iter().map(|line| line.height));
|
|
}
|
|
}
|
|
}
|
|
(first, hs)
|
|
}
|
|
|
|
#[test]
|
|
fn line_grid_snaps_line_heights_and_fixes_lines_per_page() {
|
|
let text = vec!["Grid line"; 80].join("\n");
|
|
let d = with_grid(&text, DocGridType::Lines, 0);
|
|
let (first, hs) = lines(&lay(&d));
|
|
for h in &hs {
|
|
let cells = h / 18.0;
|
|
assert!((cells - cells.round()).abs() < 1e-3 && cells >= 1.0, "line height {h} is not a multiple of the pitch");
|
|
}
|
|
// Text at the default size fits one pitch: 648 pt of text height holds 36 lines.
|
|
let h = hs[0];
|
|
assert_eq!(first, (648.0 / h + 1e-3).floor() as usize, "heights {h}");
|
|
// Turning the paragraphs' snapToGrid off leaves their natural line heights.
|
|
let mut off = d.clone();
|
|
for b in off.body.iter_mut() {
|
|
if let Block::Para(p) = std::sync::Arc::make_mut(b) {
|
|
p.props.snap_to_grid = Some(false);
|
|
}
|
|
}
|
|
let plain = with_grid(&text, DocGridType::Default, 0);
|
|
assert_eq!(lines(&lay(&off)).1[0], lines(&lay(&plain)).1[0]);
|
|
}
|
|
|
|
#[test]
|
|
fn character_grid_places_east_asian_text_on_the_pitch() {
|
|
let text = "日本語の文書abc";
|
|
// The default font size plus 2.5 pt: a pitch unlike any font's advance.
|
|
let lines = wordcraft_doc::SectionProps { doc_grid: Some(DocGrid { kind: DocGridType::Lines, ..Default::default() }), ..Default::default() };
|
|
let size = para::Grid::of(&Document::from_text(""), &lines).font_size;
|
|
let cs = DocGrid::char_space_for(size + 2.5, size);
|
|
let d = with_grid(text, DocGridType::LinesAndChars, cs);
|
|
let pitch = d.last_section.grid_char_pitch(size).unwrap();
|
|
assert!((pitch - (size + 2.5)).abs() < 0.01);
|
|
let l = lay(&d);
|
|
let plain = lay(&with_grid(text, DocGridType::Lines, cs));
|
|
let first =
|
|
|l: &DocLayout| l.pages[0].items.iter().find_map(|it| if let Placed::Lines { para, .. } = it { Some(para.clone()) } else { None }).unwrap();
|
|
let (g, p) = (first(&l), first(&plain));
|
|
let line = &g.lines[0];
|
|
let latin = text.find('a').unwrap();
|
|
for (k, c) in g.clusters.iter().enumerate() {
|
|
let x = line.xs[k] - line.xs[0];
|
|
if c.start < latin {
|
|
// Ideographs and kana sit on the pitch, one cell each.
|
|
assert!((x - pitch * k as f32).abs() < 0.01, "cluster {k} at {x}, pitch {pitch}");
|
|
assert!((c.adv - pitch).abs() < 0.01);
|
|
} else {
|
|
// Latin text keeps its own advances.
|
|
assert!((c.adv - p.clusters[k].adv).abs() < 0.01, "latin cluster {k}");
|
|
}
|
|
}
|
|
}
|