Merge pull request #6436 from batistalucasv/feat/fit-one-page-upstream

Layout: add Fit One Page (#6433)
This commit is contained in:
FredApps
2026-11-13 10:33:51 +01:00
committed by GitHub
26 changed files with 3745 additions and 21 deletions
+2 -1
View File
@@ -1,6 +1,6 @@
# WordCraft roadmap
> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (#3398: persistent screen paper colors; parity estimates unchanged); previously minor (#3693: additional Delete Previous Sentence command and shortcut; Word-parity estimates unchanged)
> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-11-12 · **Change:** minor (added layout.fitOnePage and its Layout › Page Setup ribbon button to condense a multi-page document by one page while preserving typography hierarchy); previously minor (#3398: persistent screen paper colors; parity estimates unchanged); previously minor (#3693: additional Delete Previous Sentence command and shortcut; Word-parity estimates unchanged)
> **CLI path update:** 2026-11-04 · Options before a non-Unicode input filename no longer make the CLI reject that filename; text option values still require Unicode; `run --file` and `run --script` retain native paths. No ranked gap or readiness estimate changes.
> **Floating anchor row segments:** 2026-11-07 · A floating anchor after a manual page/column break counts visible content on the far side of the same wrapped row. Marker-only cover anchors retain their previous-page rule. Synthetic picture spacing, placement and history controls; no new native Word measurement. Readiness estimates unchanged.
@@ -1444,3 +1444,4 @@ MATH glyphs that reference the same construction now share its immutable variant
> 2026-10-29 · Port #2437 to split layout modules: finite border extents and terminal-break keep preflight. Recursive keep lookahead remains outside scope. Source port only; no test execution or new native measurements.
| 2026-11-03 | trivial | Progress log: a line breaks after a hyphen before a slash (#3408) |
| 2026-11-12 | minor | Progress log: layout.fitOnePage condenses a multi-page document by one page while preserving typography hierarchy (#6433) |
+3
View File
@@ -78,6 +78,7 @@ impl Document {
media_index: MediaIndex::default(),
kept: Default::default(),
missing_pictures: Default::default(),
fit_state: None,
}
}
@@ -156,6 +157,7 @@ impl Document {
media_index: _,
kept: _,
missing_pictures: _,
fit_state: _,
} = self;
let Document {
body: other_body,
@@ -177,6 +179,7 @@ impl Document {
media_index: _,
kept: _,
missing_pictures: _,
fit_state: _,
} = other;
same_blocks(body, other_body)
&& same_parts(parts, other_parts)
+5
View File
@@ -44,6 +44,7 @@ pub mod picfx;
pub mod props;
pub mod resolve;
pub mod section;
pub mod shrink;
pub mod smart_art;
pub mod styles;
pub mod symbol_fonts;
@@ -64,6 +65,7 @@ pub use numbering::{ListKind, Numbering};
pub use para::{InlineObject, Paragraph, Run};
pub use props::{Align, CharProps, ParaProps, Rgb, TextColor};
pub use section::SectionProps;
pub use shrink::FitState;
pub use styles::{Style, StyleKind, StyleSheet};
pub use table::{Cell, Row, Table};
@@ -144,6 +146,9 @@ pub struct Document {
/// export reports them); never saved, never drawn, ignored by `==`.
#[serde(skip)]
pub missing_pictures: MissingPictures,
/// Cumulative fit-to-page typography reduction state; internal tracking outside user-visible custom properties.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fit_state: Option<shrink::FitState>,
}
#[cfg(test)]
+755
View File
@@ -0,0 +1,755 @@
//! Fit One Page typography helper.
//!
//! Adjusts font sizes and paragraph spacing across styles and editable stories
//! while preserving visual hierarchy, clamping to safe bounds, and excluding
//! fixed framing stories like headers and footers.
use std::collections::BTreeSet;
use std::sync::Arc;
use crate::edit::{each_para, each_para_mut};
use crate::para::Paragraph;
use crate::parts::PartKind;
use crate::props::{CharProps, LineSpacing, ParaProps};
use crate::styles::StyleSheet;
use crate::{Block, Document};
/// Minimum font size allowed in points (WordCraft floor).
const MIN_SHRINK_FONT_SIZE: f32 = 4.0;
/// Maximum font size allowed in points.
const MAX_SHRINK_FONT_SIZE: f32 = 1638.0;
/// Safe line spacing floor factor (preventing line collision / crowding).
const MIN_LINE_SPACING_FACTOR: f32 = 0.94;
/// Cumulative fit-to-page typography reduction state.
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct FitState {
/// Cumulative font reduction delta in points.
pub cum_delta: f32,
/// Cumulative line spacing scale factor.
pub cum_space: f32,
/// Unique effective body font sizes present immediately after the reduction was applied.
pub reduced_font_sizes: Vec<f32>,
/// Unique effective line spacing values present immediately after the reduction was applied.
pub reduced_line_spacings: Vec<LineSpacing>,
/// Unique effective paragraph spacing values present immediately after the reduction was applied.
#[serde(default)]
pub reduced_paragraph_spacings: Vec<f32>,
/// Unique effective body font sizes before the first Fit One Page reduction was applied.
#[serde(default)]
pub original_font_sizes: Vec<f32>,
/// Unique effective line spacing values before the first Fit One Page reduction was applied.
#[serde(default)]
pub original_line_spacings: Vec<LineSpacing>,
/// Unique effective paragraph spacing values before the first Fit One Page reduction was applied.
#[serde(default)]
pub original_paragraph_spacings: Vec<f32>,
/// Original effective body font sizes by formatting location key before the first fit.
#[serde(default)]
pub original_font_locations: std::collections::BTreeMap<String, f32>,
/// Original effective line spacing values by formatting location key before the first fit.
#[serde(default)]
pub original_line_spacing_locations: std::collections::BTreeMap<String, LineSpacing>,
/// Original effective paragraph spacing values by formatting location key before the first fit.
#[serde(default)]
pub original_paragraph_spacing_locations: std::collections::BTreeMap<String, f32>,
}
/// Scale a font size down by `delta_pt`.
#[inline]
fn scale_size(size: f32, delta_pt: f32) -> f32 {
if delta_pt == 0.0 {
return size;
}
// Round up so the reduction never exceeds `delta_pt`, and never enlarge.
let target = (size - delta_pt).clamp(MIN_SHRINK_FONT_SIZE, MAX_SHRINK_FONT_SIZE);
((target * 2.0).ceil() / 2.0).min(size)
}
/// Scale a spacing value (e.g. `space_before`, `space_after`) by `space_factor`.
///
/// Caps the result at the original nonnegative spacing value and rounds up to hundredths (with a
/// tiny epsilon for floating-point noise), so a trial never removes more spacing than its factor
/// permits and never increases spacing. Rounding down would over-compress very small values.
#[inline]
fn scale_spacing(val: f32, space_factor: f32) -> f32 {
let non_neg = val.max(0.0);
let scaled = (non_neg * space_factor).clamp(0.0, 1584.0);
((scaled * 100.0 - 1e-4).ceil() / 100.0).min(non_neg)
}
/// Scale fixed line spacing in points respecting the safe readability floor and rounding upward to half-points.
///
/// Rounds upward to half-points so the result never falls below the 0.94 floor, and caps at `pts` so
/// reduction never increases fixed line spacing.
#[inline]
fn scale_fixed_line_spacing(pts: f32, space_factor: f32) -> f32 {
let floor = pts * MIN_LINE_SPACING_FACTOR;
let scaled = (pts * space_factor).max(floor).clamp(0.0, 1584.0);
// Round upward to half-points (0.5 pt) so the resulting spacing never falls below the 0.94 floor.
((scaled * 2.0).ceil() / 2.0).min(pts)
}
/// Scale line spacing by `space_factor` respecting the safe readability floor.
#[inline]
fn scale_line_spacing(ls: LineSpacing, space_factor: f32) -> LineSpacing {
match ls {
LineSpacing::Multiple(m) => {
// Guard against ascender/descender collisions: keep line spacing at or above
// safe readability floor of 0.94 factor (~6% max compression).
let floor = m * MIN_LINE_SPACING_FACTOR;
let new_m = (m * space_factor).max(floor).clamp(0.5, 132.0);
// Round upward to a hundredth so the resulting multiple never falls below the 0.94 floor.
LineSpacing::Multiple(((new_m * 100.0).ceil() / 100.0).min(m))
}
LineSpacing::AtLeast(pts) => LineSpacing::AtLeast(scale_fixed_line_spacing(pts, space_factor)),
LineSpacing::Exactly(pts) => LineSpacing::Exactly(scale_fixed_line_spacing(pts, space_factor)),
}
}
/// Scale explicit font sizes on a `CharProps`.
#[inline]
fn scale_char_props(c: &mut CharProps, delta_pt: f32) {
if delta_pt > 0.0 {
if let Some(s) = c.size.as_mut() {
*s = scale_size(*s, delta_pt);
}
if let Some(s) = c.size_cs.as_mut() {
*s = scale_size(*s, delta_pt);
}
}
}
/// Scale explicit paragraph spacing on a `ParaProps`.
#[inline]
fn scale_para_props(p: &mut ParaProps, space_factor: f32) {
if space_factor < 1.0 - f32::EPSILON {
if let Some(sb) = p.space_before.as_mut() {
*sb = scale_spacing(*sb, space_factor);
}
if let Some(sa) = p.space_after.as_mut() {
*sa = scale_spacing(*sa, space_factor);
}
if let Some(ls) = p.line_spacing.as_mut() {
*ls = scale_line_spacing(*ls, space_factor);
}
}
}
/// Finds all text box IDs transitively reachable from Header and Footer parts
/// (including linked text box chains and nested text boxes).
pub fn header_footer_text_boxes(doc: &Document) -> BTreeSet<u32> {
let mut hf_boxes: BTreeSet<u32> = BTreeSet::new();
let mut queue: Vec<u32> = Vec::new();
for part in doc.parts.values() {
if matches!(part.kind, PartKind::Header | PartKind::Footer) {
for b in &part.blocks {
each_para(b, 0, &mut |p| {
for obj in &p.objects {
for box_id in obj.text_boxes() {
if hf_boxes.insert(box_id) {
queue.push(box_id);
}
}
}
});
}
}
}
while let Some(box_id) = queue.pop() {
if let Some(part) = doc.parts.get(&box_id) {
if let Some(next_id) = part.body.next
&& hf_boxes.insert(next_id)
{
queue.push(next_id);
}
for b in &part.blocks {
each_para(b, 0, &mut |p| {
for obj in &p.objects {
for nested_id in obj.text_boxes() {
if hf_boxes.insert(nested_id) {
queue.push(nested_id);
}
}
}
});
}
}
}
hf_boxes
}
/// Freezes resolved character and paragraph formatting on a single paragraph using the provided stylesheet and optional table-style context.
fn freeze_para_formatting(
p: &mut Paragraph,
styles: &StyleSheet,
table_chr: Option<&CharProps>,
table_para: Option<&ParaProps>,
delta_pt: f32,
space_factor: f32,
) {
let style = p.props.style.clone();
if delta_pt > 0.0 {
for r in &mut p.runs {
if r.props.size.is_none() {
let resolved = styles.resolve_char_in(style.as_deref(), table_chr, &r.props).size;
r.props.size = Some(resolved);
}
if r.props.size_cs.is_none() {
let resolved_cs = styles.resolve_char_in(style.as_deref(), table_chr, &r.props).size_cs;
r.props.size_cs = Some(resolved_cs);
}
}
if p.mark.size.is_none() {
let resolved = styles.resolve_char_in(style.as_deref(), table_chr, &p.mark).size;
p.mark.size = Some(resolved);
}
if p.mark.size_cs.is_none() {
let resolved_cs = styles.resolve_char_in(style.as_deref(), table_chr, &p.mark).size_cs;
p.mark.size_cs = Some(resolved_cs);
}
}
if space_factor < 1.0 - f32::EPSILON {
let resolved_p = styles.resolve_para_in(&p.props, table_para);
if p.props.space_before.is_none() {
p.props.space_before = Some(resolved_p.space_before);
}
if p.props.space_after.is_none() {
p.props.space_after = Some(resolved_p.space_after);
}
if p.props.line_spacing.is_none() {
p.props.line_spacing = Some(resolved_p.line_spacing);
}
}
}
/// Recursively freezes resolved formatting on blocks while preserving table-style context.
fn freeze_blocks_formatting(
blocks: &mut [Arc<Block>],
styles: &StyleSheet,
table_chr: Option<&CharProps>,
table_para: Option<&ParaProps>,
delta_pt: f32,
space_factor: f32,
) {
use crate::styles::PartSet;
for b in blocks {
let b = Arc::make_mut(b);
match b {
Block::Para(p) => {
freeze_para_formatting(p, styles, table_chr, table_para, delta_pt, space_factor);
}
Block::Table(tbl) => {
let style = tbl.props.style.as_deref().and_then(|id| styles.table_style(id));
let cell_para = style.as_ref().map(|st| &st.para);
let nrows = tbl.rows.len();
let ncols = tbl.cols();
let cell_grids: Vec<Vec<usize>> =
(0..nrows).map(|ri| (0..tbl.rows[ri].cells.len()).map(|ci| tbl.grid_col(ri, ci)).collect()).collect();
for (ri, row) in tbl.rows.iter_mut().enumerate() {
for (ci, cell) in row.cells.iter_mut().enumerate() {
let mut cell_chr = CharProps::default();
if let Some(st) = &style {
let sizes = (st.parts.band_size.unwrap_or(0), st.parts.col_band_size.unwrap_or(0));
let g = cell_grids.get(ri).and_then(|r| r.get(ci)).copied().unwrap_or(0);
let span = cell.span();
let region = PartSet::of_cell(&tbl.props.look, sizes, (ri, nrows), (g, span, ncols));
cell_chr.overlay(&st.chr);
for part in region.iter() {
let f = st.part(part);
cell_chr.overlay(&f.chr);
}
}
freeze_blocks_formatting(&mut cell.blocks, styles, Some(&cell_chr), cell_para, delta_pt, space_factor);
}
}
}
}
}
}
/// Fits font sizes across the document by `delta_pt` points and scales paragraph
/// spacing by `space_factor`.
///
/// Clean-room typographical helper for Fit One Page. Preserves relative typography
/// hierarchies between headings, body text, and direct run formatting.
/// Clamps font sizes to `[4.0, 1638.0]` pt and rounds to 0.5 pt (half-points).
/// Headers, footers, and comments are explicitly excluded and retain their exact original sizes.
/// Footnotes are included and shrink alongside body text.
pub fn fit_document_typography(doc: &mut Document, delta_pt: f32, space_factor: f32) {
if !delta_pt.is_finite() || !space_factor.is_finite() {
return;
}
let delta_pt = delta_pt.max(0.0);
let space_factor = space_factor.clamp(0.1, 1.0);
if delta_pt == 0.0 && (space_factor - 1.0).abs() < f32::EPSILON {
return;
}
// 1. Find all text boxes reachable from Header and Footer parts (including nested text boxes and chains).
let hf_boxes = header_footer_text_boxes(doc);
// Freeze header, footer, and comment font sizes and spacing (and reachable text boxes) before modifying styles,
// preserving table-style context so header/footer table cells retain their conditional table formatting,
// and comment stories retain their inherited formatting from shared styles.
let old_styles = doc.styles.clone();
for (id, part) in doc.parts.iter_mut() {
if matches!(part.kind, PartKind::Header | PartKind::Footer | PartKind::Comment) || hf_boxes.contains(id) {
freeze_blocks_formatting(&mut part.blocks, &old_styles, None, None, delta_pt, space_factor);
}
}
// 2. Adjust styles: document defaults, character styles, paragraph styles, table style regions.
let st = Arc::make_mut(&mut doc.styles);
if delta_pt > 0.0 {
// Default character formatting
let default_size = st.default_chr.size.unwrap_or(crate::props::DEFAULT_FONT_SIZE);
st.default_chr.size = Some(scale_size(default_size, delta_pt));
if let Some(cs) = st.default_chr.size_cs.as_mut() {
*cs = scale_size(*cs, delta_pt);
}
}
// Default paragraph formatting
scale_para_props(&mut st.default_para, space_factor);
// All named styles (excluding header/footer styles if any)
for style in &mut st.styles {
let is_hf = style.id.eq_ignore_ascii_case("header")
|| style.id.eq_ignore_ascii_case("footer")
|| style.name.eq_ignore_ascii_case("header")
|| style.name.eq_ignore_ascii_case("footer");
if is_hf {
continue;
}
if delta_pt > 0.0 {
scale_char_props(&mut style.chr, delta_pt);
}
scale_para_props(&mut style.para, space_factor);
if let Some(t) = style.table.as_mut()
&& delta_pt > 0.0
{
for c in [&mut t.header_chr, &mut t.band_chr, &mut t.first_col_chr, &mut t.last_col_chr, &mut t.total_chr, &mut t.col_band_chr] {
scale_char_props(c, delta_pt);
}
for part in t.regions.values_mut() {
scale_char_props(&mut part.chr, delta_pt);
}
}
}
// 3. Helper to shrink editable paragraph content (runs, mark, spacing).
let mut shrink_paragraph = |p: &mut Paragraph| {
scale_para_props(&mut p.props, space_factor);
if delta_pt > 0.0 {
// Scale explicit paragraph mark size
scale_char_props(&mut p.mark, delta_pt);
// Scale explicit run sizes
for r in &mut p.runs {
scale_char_props(&mut r.props, delta_pt);
}
}
p.touch();
};
// 4. Traverse main body story.
for b in &mut doc.body {
each_para_mut(Arc::make_mut(b), 0, &mut shrink_paragraph);
}
// 5. Traverse secondary editable stories (footnotes, body text boxes), excluding headers, footers,
// comments, and text boxes reachable from headers or footers.
for (id, part) in doc.parts.iter_mut() {
if matches!(part.kind, PartKind::Header | PartKind::Footer | PartKind::Comment) || hf_boxes.contains(id) {
continue;
}
for b in &mut part.blocks {
each_para_mut(Arc::make_mut(b), 0, &mut shrink_paragraph);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::block::Block;
use crate::para::Paragraph;
use crate::props::{CharProps, LineSpacing};
use crate::table::Table;
use crate::{Document, para_block};
#[test]
fn test_shrink_preserves_hierarchy() {
let mut doc = Document::new();
// Setup Heading 1 paragraph and Normal paragraph
let h1_para = Paragraph::with_text("Heading 1 Title", CharProps::default()).styled("Heading1");
let normal_para = Paragraph::with_text("Body text in Normal style", CharProps::default()).styled("Normal");
let mut custom_run_para = Paragraph::with_text("Normal prefix ", CharProps::default()).styled("Normal");
custom_run_para.insert_text(custom_run_para.len(), "Special large text", &CharProps { size: Some(24.0), ..Default::default() }).unwrap();
doc.body = vec![para_block(h1_para), para_block(normal_para), para_block(custom_run_para)];
let h1_before = doc.styles.resolve_char(Some("Heading1"), &CharProps::default()).size;
let normal_before = doc.styles.resolve_char(Some("Normal"), &CharProps::default()).size;
fit_document_typography(&mut doc, 0.5, 1.0);
let h1_after = doc.styles.resolve_char(Some("Heading1"), &CharProps::default()).size;
let normal_after = doc.styles.resolve_char(Some("Normal"), &CharProps::default()).size;
// Both styles scaled down by exactly 0.5 pt
assert_eq!(h1_after, h1_before - 0.5);
assert_eq!(normal_after, normal_before - 0.5);
// Relative hierarchy preserved
assert_eq!(h1_after - normal_after, h1_before - normal_before);
// Explicit run in paragraph 3 was 24.0 pt, now 23.5 pt
let p3 = doc.body[2].as_para().unwrap();
assert_eq!(p3.runs[1].props.size, Some(23.5));
}
#[test]
fn test_shrink_excludes_headers_and_footers() {
let mut doc = Document::new();
let body_para = Paragraph::with_text("Main story body text", CharProps::default());
doc.body = vec![para_block(body_para)];
let header_para = Paragraph::with_text("Header Text", CharProps { size: Some(10.0), ..Default::default() });
let footer_para = Paragraph::with_text("Footer Text Page 1", CharProps::default()); // inherits
let h_id = doc.add_part(PartKind::Header, vec![para_block(header_para)]);
let f_id = doc.add_part(PartKind::Footer, vec![para_block(footer_para)]);
let footer_before_size = doc.styles.resolve_char(None, &CharProps::default()).size;
fit_document_typography(&mut doc, 0.5, 1.0);
// Body default style shrank
let body_after_size = doc.styles.resolve_char(None, &CharProps::default()).size;
assert_eq!(body_after_size, footer_before_size - 0.5);
// Header retained its explicit 10.0 pt size
let h_part = doc.parts.get(&h_id).unwrap();
let h_p = h_part.blocks[0].as_para().unwrap();
assert_eq!(h_p.runs[0].props.size, Some(10.0));
// Footer retained its original resolved size
let f_part = doc.parts.get(&f_id).unwrap();
let f_p = f_part.blocks[0].as_para().unwrap();
assert_eq!(f_p.runs[0].props.size, Some(footer_before_size));
}
#[test]
fn test_shrink_scales_footnotes() {
let mut doc = Document::new();
let body_para = Paragraph::with_text("Main body with note", CharProps::default());
doc.body = vec![para_block(body_para)];
let fn_para = Paragraph::with_text("Footnote content", CharProps { size: Some(10.0), ..Default::default() });
let fn_id = doc.add_part(PartKind::Footnote, vec![para_block(fn_para)]);
fit_document_typography(&mut doc, 0.5, 1.0);
let fn_part = doc.parts.get(&fn_id).unwrap();
let fn_p = fn_part.blocks[0].as_para().unwrap();
assert_eq!(fn_p.runs[0].props.size, Some(9.5));
}
#[test]
fn test_shrink_clamps_to_min_floor() {
let mut doc = Document::new();
let mut p = Paragraph::with_text("Very tiny text", CharProps { size: Some(4.2), ..Default::default() });
p.insert_text(p.len(), "Floor text", &CharProps { size: Some(4.0), ..Default::default() }).unwrap();
doc.body = vec![para_block(p)];
fit_document_typography(&mut doc, 1.0, 1.0);
let p_after = doc.body[0].as_para().unwrap();
assert_eq!(p_after.runs[0].props.size, Some(4.0));
assert_eq!(p_after.runs[1].props.size, Some(4.0));
}
#[test]
fn test_shrink_zero_delta_identity() {
let mut doc = Document::new();
let h1_para = Paragraph::with_text("Heading 1 Title", CharProps::default()).styled("Heading1");
let normal_para = Paragraph::with_text("Body text", CharProps::default());
doc.body = vec![para_block(h1_para), para_block(normal_para)];
doc.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Header", CharProps::default()))]);
doc.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Note", CharProps::default()))]);
let before = doc.clone();
fit_document_typography(&mut doc, 0.0, 1.0);
assert_eq!(doc, before);
}
#[test]
fn test_shrink_paragraph_spacing() {
let mut doc = Document::new();
let mut p = Paragraph::with_text("Spaced paragraph", CharProps::default());
p.props.space_before = Some(18.0);
p.props.space_after = Some(12.0);
p.props.line_spacing = Some(LineSpacing::Multiple(1.15));
doc.body = vec![para_block(p)];
fit_document_typography(&mut doc, 0.5, 0.9);
let p_after = doc.body[0].as_para().unwrap();
// 18.0 * 0.9 = 16.2, capped at the requested reduction
assert_eq!(p_after.props.space_before, Some(16.2));
// 12.0 * 0.9 = 10.8, capped at the requested reduction
assert_eq!(p_after.props.space_after, Some(10.8));
// 1.15 * 0.9 = 1.035 -> clamped to safe line spacing floor (1.15 * 0.94 = 1.081) -> ceil hundredth 1.09
assert_eq!(p_after.props.line_spacing, Some(LineSpacing::Multiple(1.09)));
}
#[test]
fn spacing_scaling_never_exceeds_requested_compression() {
// 0.26 * 0.94 = 0.2444 pt: it must not round down to zero and remove all spacing.
let tiny = scale_spacing(0.26, 0.94);
assert!(tiny > 0.0 && tiny <= 0.26, "tiny spacing must shrink without vanishing: {tiny}");
// 0.019 * 0.94 = 0.01786 pt: rounding down to hundredths would remove 47%, so round up.
assert_eq!(scale_spacing(0.019, 0.94), 0.019);
// 18.0 * 0.9 = 16.2 pt: the reduction stays at the requested 1.8 pt.
assert_eq!(scale_spacing(18.0, 0.9), 16.2);
assert_eq!(scale_spacing(0.0, 0.9), 0.0);
}
#[test]
fn test_line_spacing_floor_prevents_overlap() {
let mut doc = Document::new();
let mut p = Paragraph::with_text("Single spaced text", CharProps::default());
p.props.line_spacing = Some(LineSpacing::Multiple(1.0));
doc.body = vec![para_block(p)];
// Even with aggressive space_factor = 0.5, line spacing multiple must not fall below safe floor (0.94)
fit_document_typography(&mut doc, 0.5, 0.5);
let p_after = doc.body[0].as_para().unwrap();
assert_eq!(p_after.props.line_spacing, Some(LineSpacing::Multiple(0.94)));
}
#[test]
fn test_header_text_boxes_are_frozen_and_do_not_shrink() {
let mut doc = Document::new();
let body_p = Paragraph::with_text("Body text", CharProps { size: Some(12.0), ..Default::default() });
doc.body = vec![para_block(body_p)];
let header_box_id = doc
.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("Header box", CharProps { size: Some(14.0), ..Default::default() }))]);
let mut header_p = Paragraph::with_text("Header prefix ", CharProps { size: Some(10.0), ..Default::default() });
let shape = crate::para::InlineObject::Shape {
kind: crate::para::ShapeKind::TextBox,
w: 144.0,
h: 72.0,
fill: None,
stroke: None,
stroke_width: 0.75,
float: Default::default(),
story: Some(header_box_id),
freeform: None,
effects: Default::default(),
extra: Default::default(),
};
header_p.objects.push(shape);
doc.add_part(PartKind::Header, vec![para_block(header_p)]);
let body_box_id =
doc.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("Body box", CharProps { size: Some(16.0), ..Default::default() }))]);
fit_document_typography(&mut doc, 2.0, 1.0);
let b_p = doc.body[0].as_para().unwrap();
assert_eq!(b_p.runs[0].props.size, Some(10.0));
let bb_part = doc.parts.get(&body_box_id).unwrap();
let bb_p = bb_part.blocks[0].as_para().unwrap();
assert_eq!(bb_p.runs[0].props.size, Some(14.0));
let hb_part = doc.parts.get(&header_box_id).unwrap();
let hb_p = hb_part.blocks[0].as_para().unwrap();
assert_eq!(hb_p.runs[0].props.size, Some(14.0));
}
#[test]
fn test_shrink_table_cells() {
let mut doc = Document::new();
let mut table = Table::new(1, 1, 100.0);
let cell_p = Paragraph::with_text("Cell text", CharProps { size: Some(12.0), ..Default::default() });
table.rows[0].cells[0].blocks = vec![para_block(cell_p)];
doc.body = vec![Arc::new(Block::Table(table))];
fit_document_typography(&mut doc, 0.5, 1.0);
let tbl = doc.body[0].as_table().unwrap();
let cell_p_after = tbl.rows[0].cells[0].blocks[0].as_para().unwrap();
assert_eq!(cell_p_after.runs[0].props.size, Some(11.5));
}
#[test]
fn test_exact_line_spacing_preserves_floor_upward() {
// LineSpacing::Exactly(7.5) with space_factor = 0.90 (below MIN_LINE_SPACING_FACTOR 0.94)
// Floor = 7.5 * 0.94 = 7.05 pt. Rounding upward to half-points yields 7.5 pt (>= 7.05 pt).
let ls = scale_line_spacing(LineSpacing::Exactly(7.5), 0.90);
assert_eq!(ls, LineSpacing::Exactly(7.5));
// LineSpacing::Exactly(10.0) with space_factor = 0.90:
// Floor = 10.0 * 0.94 = 9.4 pt. Rounding upward to half-points yields 9.5 pt (>= 9.4 pt).
let ls2 = scale_line_spacing(LineSpacing::Exactly(10.0), 0.90);
assert_eq!(ls2, LineSpacing::Exactly(9.5));
}
#[test]
fn test_spacing_only_leaves_unrounded_font_sizes_unchanged() {
let mut doc = Document::new();
// A run with fractional 9.3 pt font
let p = Paragraph::with_text("Unrounded size text", CharProps { size: Some(9.3), ..Default::default() });
doc.body = vec![para_block(p)];
// Spacing-only trial (delta_pt = 0.0, space_factor = 0.94)
fit_document_typography(&mut doc, 0.0, 0.94);
let p_after = doc.body[0].as_para().unwrap();
assert_eq!(p_after.runs[0].props.size, Some(9.3), "spacing-only trial must not round or scale font size");
}
#[test]
fn test_scale_size_never_enlarges_sub_floor_font() {
// 2.0 pt run (below 4.0 pt floor) with delta 0.5 pt must not be enlarged to 4.0 pt
assert_eq!(scale_size(2.0, 0.5), 2.0);
// 3.0 pt run with delta 0.5 pt must not be enlarged to 4.0 pt
assert_eq!(scale_size(3.0, 0.5), 3.0);
// 5.0 pt run with delta 0.5 pt shrinks to 4.5 pt
assert_eq!(scale_size(5.0, 0.5), 4.5);
// 12.0 pt run with delta 0.3 pt rounds up to 12.0 pt so reduction (0.0 pt) never exceeds delta (0.3 pt)
assert_eq!(scale_size(12.0, 0.3), 12.0);
// Zero delta leaves size unchanged
assert_eq!(scale_size(2.0, 0.0), 2.0);
}
#[test]
fn test_shrink_excludes_comment_stories() {
let mut doc = Document::new();
let comment_p = Paragraph::with_text("Comment text", CharProps { size: Some(8.0), ..Default::default() });
let comment_id = doc.add_part(PartKind::Comment, vec![para_block(comment_p)]);
// Shrink with 1.0 pt delta
fit_document_typography(&mut doc, 1.0, 0.94);
let comment_part = doc.parts.get(&comment_id).unwrap();
let p_after = comment_part.blocks[0].as_para().unwrap();
assert_eq!(p_after.runs[0].props.size, Some(8.0), "comments must be excluded from typography shrink pass");
}
#[test]
fn test_shrink_preserves_comment_inherited_formatting() {
let mut doc = Document::new();
// Setup a named style "CommentText" with 12.0 pt font and 6.0 pt spacing after.
let comment_style = crate::styles::Style {
id: "CommentText".into(),
name: "Comment Text".into(),
kind: crate::styles::StyleKind::Paragraph,
chr: CharProps { size: Some(12.0), ..Default::default() },
para: ParaProps { space_after: Some(6.0), line_spacing: Some(LineSpacing::Multiple(1.2)), ..Default::default() },
..Default::default()
};
Arc::make_mut(&mut doc.styles).upsert(comment_style);
// Comment paragraph inheriting from "CommentText" without explicit run size or paragraph spacing
let mut comment_p = Paragraph::with_text("Inherited comment text", CharProps::default());
comment_p.props.style = Some("CommentText".to_string());
let comment_id = doc.add_part(PartKind::Comment, vec![para_block(comment_p)]);
// Shrink with 1.0 pt delta and 0.94 space factor
fit_document_typography(&mut doc, 1.0, 0.94);
// Shared style "CommentText" is scaled (12.0 -> 11.0 pt)
let scaled_style = doc.styles.get("CommentText").unwrap();
assert_eq!(scaled_style.chr.size, Some(11.0));
// The comment story must have frozen its original inherited formatting before styles were modified
let comment_part = doc.parts.get(&comment_id).unwrap();
let p_after = comment_part.blocks[0].as_para().unwrap();
assert_eq!(p_after.runs[0].props.size, Some(12.0), "comment run size must remain 12.0 pt from old styles");
assert_eq!(p_after.props.space_after, Some(6.0), "comment paragraph space_after must remain 6.0 pt from old styles");
assert_eq!(p_after.props.line_spacing, Some(LineSpacing::Multiple(1.2)), "comment paragraph line_spacing must remain 1.2 from old styles");
}
#[test]
fn test_header_table_text_inherits_size_from_table_style() {
let mut doc = Document::new();
// Setup a custom table style with 9.0 pt font
let st = crate::styles::Style {
id: "HeaderTableStyle".into(),
name: "HeaderTableStyle".into(),
kind: crate::styles::StyleKind::Table,
chr: CharProps { size: Some(9.0), ..Default::default() },
table: Some(crate::styles::TableStyleParts::default()),
..Default::default()
};
Arc::make_mut(&mut doc.styles).upsert(st);
// Create a table in a Header part that uses HeaderTableStyle
let mut table = crate::Table::new(1, 1, 100.0);
table.props.style = Some("HeaderTableStyle".to_string());
// Cell paragraph with no explicit font size on run (inherits from table style)
let cell_p = Paragraph::with_text("Header cell text", CharProps::default());
table.rows[0].cells[0].blocks = vec![crate::para_block(cell_p)];
let header_id = doc.add_part(PartKind::Header, vec![Arc::new(Block::Table(table))]);
// Run shrink with delta_pt = 1.0 (body font would shrink)
fit_document_typography(&mut doc, 1.0, 0.95);
// Verify that the header table cell's run preserved the 9.0 pt size from the table style,
// and was not frozen to the document default (11.0 pt)
let header_part = doc.parts.get(&header_id).unwrap();
if let Block::Table(tbl) = header_part.blocks[0].as_ref() {
let p = tbl.rows[0].cells[0].blocks[0].as_para().unwrap();
assert_eq!(p.runs[0].props.size, Some(9.0), "header table text must inherit and freeze table style font size");
} else {
panic!("expected table in header part");
}
}
#[test]
fn test_scale_line_spacing_never_increases_fixed_or_multiple() {
// LineSpacing::Exactly(7.45) with space_factor = 0.94:
// 7.45 * 0.94 = 7.003 -> ceil to half-point would yield 7.5 pt, but capping at 7.45 ensures no enlargement
assert_eq!(scale_fixed_line_spacing(7.45, 0.94), 7.45);
// Regular scaling: 10.0 pt with 0.94 -> 9.4 -> ceil to half-point = 9.5 pt <= 10.0 pt
assert_eq!(scale_fixed_line_spacing(10.0, 0.94), 9.5);
// LineSpacing::Multiple with small factor never enlarges
let ls = LineSpacing::Multiple(0.942);
let scaled = scale_line_spacing(ls, 0.94);
if let LineSpacing::Multiple(m) = scaled {
assert!(m <= 0.942);
} else {
panic!("expected Multiple");
}
}
#[test]
fn test_scale_spacing_never_increases_value() {
// Spacing 0.3 pt scaled by 0.94 yields 0.282 pt, rounded up to 0.29 and never above 0.3 pt.
assert_eq!(scale_spacing(0.3, 0.94), 0.29);
// 12.0 pt * 0.94 = 11.28 pt, capped at the requested reduction.
assert_eq!(scale_spacing(12.0, 0.94), 11.28);
// Zero spacing remains zero.
assert_eq!(scale_spacing(0.0, 0.94), 0.0);
}
}
+1 -1
View File
@@ -15,6 +15,7 @@ quick-xml = { workspace = true }
zip = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }
unicode-bidi = { workspace = true }
cfb = { workspace = true }
@@ -32,7 +33,6 @@ getrandom = { workspace = true, features = ["wasm_js"] }
[dev-dependencies]
wordcraft-doc = { workspace = true, features = ["test-support"] }
serde_json = { workspace = true }
proptest = { workspace = true }
image = { workspace = true }
zip = { workspace = true }
+70
View File
@@ -0,0 +1,70 @@
//! The Fit One Page budget in a DOCX custom XML part.
//!
//! Word keeps custom XML stores as ordinary package parts; our own store is namespaced so it can
//! never be mistaken for a user's data. It is not a document property, so it stays out of
//! `docProps/custom.xml`.
use wordcraft_doc::FitState;
use crate::DocxError;
use crate::xml::{El, esc};
/// The part this crate writes and reads back.
pub(crate) const PART: &str = "customxml/wordcraftfit.xml";
/// Root element of the store.
pub(crate) const ROOT: &str = "wordcraftFit";
/// Namespace of the store.
pub(crate) const NS: &str = "urn:wordcraft:fit";
/// Marker attribute the parser keeps (namespace declarations are stripped from `El`).
const KIND: &str = "wordcraft-fit-v1";
/// Whether `root` is our store (and not a user's custom XML part with a coincidental name).
pub(crate) fn is_state(root: &El) -> bool {
root.local() == ROOT && root.attr("kind") == Some(KIND)
}
/// Serialize `fit` as the custom XML store.
pub(crate) fn write(fit: &FitState) -> Result<Vec<u8>, DocxError> {
let json = serde_json::to_string(fit).map_err(|e| DocxError::Xml(e.to_string()))?;
Ok(format!(r#"<?xml version="1.0" encoding="UTF-8"?><{ROOT} xmlns="{NS}" kind="{KIND}"><fitState>{}</fitState></{ROOT}>"#, esc(&json))
.into_bytes())
}
/// Read the state from a parsed custom XML root, if it is ours.
pub(crate) fn read(root: &El) -> Option<FitState> {
if !is_state(root) {
return None;
}
let state = root.els().find(|e| e.local() == "fitState")?;
serde_json::from_str(&state.text()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use wordcraft_doc::props::LineSpacing;
fn state() -> FitState {
FitState {
cum_delta: 1.0,
cum_space: 0.94,
reduced_font_sizes: vec![11.0],
reduced_line_spacings: vec![LineSpacing::Multiple(0.94)],
reduced_paragraph_spacings: vec![18.8],
original_font_sizes: vec![12.0],
original_line_spacings: vec![LineSpacing::Multiple(1.0)],
original_paragraph_spacings: vec![20.0],
..Default::default()
}
}
/// The cumulative budget survives a real DOCX write and read, not just `serde_json`.
#[test]
fn fit_state_round_trips_through_docx() {
let mut doc = wordcraft_doc::Document::from_text("Alpha\nBeta");
doc.fit_state = Some(state());
let bytes = crate::write(&doc).expect("write docx");
let back = crate::read(&bytes).expect("read docx");
assert_eq!(back.fit_state, doc.fit_state);
}
}
+1
View File
@@ -11,6 +11,7 @@ mod bibliography;
mod chart_spec;
mod crypt;
mod custom;
mod fit;
mod flat;
mod fonts;
mod package;
+1
View File
@@ -331,6 +331,7 @@ fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize,
r.read_vba_related(&v);
}
r.read_glossary(&rels);
r.read_fit_state(&root_rels, &rels);
r.keep_custom_xml(&root_rels, &rels);
r.keep_unmodelled(&root, &root_rels, &rels);
if !r.embedded.parts.is_empty() {
+125 -1
View File
@@ -120,7 +120,13 @@ impl Reader<'_> {
if ![&rel.kind, &rel.target].iter().all(|s| manifest_field_ok(s)) {
continue;
}
if !self.doc.sources.is_empty() && self.xml(&rel.target).ok().flatten().is_some_and(|r| r.local() == "Sources") {
let root = self.xml(&rel.target).ok().flatten();
if self.doc.fit_state.is_some() && root.as_ref().is_some_and(crate::fit::is_state) {
// A marked Fit One Page store is consumed whatever part name it has; the
// writer recreates it under the reserved name.
continue;
}
if !self.doc.sources.is_empty() && root.as_ref().is_some_and(|r| r.local() == "Sources") {
continue;
}
// Native ink shares the custom XML relationship type, but its part belongs to the
@@ -139,6 +145,24 @@ impl Reader<'_> {
}
}
/// Read the Fit One Page budget from our custom XML store, if the file has one.
///
/// Inspects custom XML relationships referenced by either the document or package root.
pub(super) fn read_fit_state(&mut self, package: &Rels, document: &Rels) {
for rels in [document, package] {
for rel in rels.list.iter().filter(|r| !r.external && rel_is(&r.kind, rt::CUSTOM_XML)).take(MAX_EMBEDDED_RELS) {
if !manifest_field_ok(&rel.target) {
continue;
}
let Some(root) = self.xml(&rel.target).ok().flatten() else { continue };
if let Some(fit) = crate::fit::read(&root) {
self.doc.fit_state = Some(fit);
return;
}
}
}
}
/// Keep the parts the package and the main document relate to that WordCraft neither models
/// nor writes itself (web settings, styles-with-effects, add-in task panes, the thumbnail, a
/// glossary…), with every part they reach, so saving carries them unchanged. Skipped: the
@@ -339,6 +363,106 @@ mod tests {
zw.finish().unwrap().into_inner()
}
/// A package whose main part relates to one custom XML store.
fn custom_xml_package(target: &str, part: &str, bytes: &[u8]) -> Vec<u8> {
let doc = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document {W_NS}><w:body/></w:document>"#);
let r = format!(
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml" Target="{target}"/></Relationships>"#
);
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let opts = zip::write::SimpleFileOptions::default();
zw.start_file("[Content_Types].xml", opts).unwrap();
zw.write_all(CONTENT_TYPES.as_bytes()).unwrap();
zw.start_file("_rels/.rels", opts).unwrap();
zw.write_all(ROOT_RELS.as_bytes()).unwrap();
zw.start_file("word/document.xml", opts).unwrap();
zw.write_all(doc.as_bytes()).unwrap();
zw.start_file("word/_rels/document.xml.rels", opts).unwrap();
zw.write_all(r.as_bytes()).unwrap();
zw.start_file(part, opts).unwrap();
zw.write_all(bytes).unwrap();
zw.finish().unwrap().into_inner()
}
/// A package whose package-level relationships refer to one custom XML store.
fn package_level_custom_xml_package(target: &str, part: &str, bytes: &[u8]) -> Vec<u8> {
let doc = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document {W_NS}><w:body/></w:document>"#);
let root_rels = format!(
r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/><Relationship Id="rIdFit" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml" Target="{target}"/></Relationships>"#
);
let r = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"/>"#;
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let opts = zip::write::SimpleFileOptions::default();
zw.start_file("[Content_Types].xml", opts).unwrap();
zw.write_all(CONTENT_TYPES.as_bytes()).unwrap();
zw.start_file("_rels/.rels", opts).unwrap();
zw.write_all(root_rels.as_bytes()).unwrap();
zw.start_file("word/document.xml", opts).unwrap();
zw.write_all(doc.as_bytes()).unwrap();
zw.start_file("word/_rels/document.xml.rels", opts).unwrap();
zw.write_all(r.as_bytes()).unwrap();
zw.start_file(part, opts).unwrap();
zw.write_all(bytes).unwrap();
zw.finish().unwrap().into_inner()
}
/// The marked store is recognized by its root, so a renamed part still restores the budget.
#[test]
fn a_marked_fit_state_loads_from_a_renamed_custom_xml_part() {
let fit = wordcraft_doc::FitState { cum_delta: 1.0, cum_space: 0.94, ..Default::default() };
let state = crate::fit::write(&fit).unwrap();
let bytes = custom_xml_package("../customxml/item2.xml", "customxml/item2.xml", &state);
let doc = crate::read(&bytes).unwrap();
assert_eq!(doc.fit_state, Some(fit));
assert!(!doc.passthrough.keys().any(|k| k.eq_ignore_ascii_case("customxml/item2.xml")), "the consumed store must not be kept in passthrough");
}
/// A package-level custom XML relationship restores the budget and survives saving.
#[test]
fn a_marked_fit_state_loads_from_a_package_level_custom_xml_relationship() {
let fit = wordcraft_doc::FitState { cum_delta: 1.0, cum_space: 0.94, ..Default::default() };
let state = crate::fit::write(&fit).unwrap();
let bytes = package_level_custom_xml_package("customxml/item2.xml", "customxml/item2.xml", &state);
let doc = crate::read(&bytes).unwrap();
assert_eq!(doc.fit_state, Some(fit));
assert!(!doc.passthrough.keys().any(|k| k.eq_ignore_ascii_case("customxml/item2.xml")), "the consumed store must not be kept in passthrough");
let saved = crate::write(&doc).unwrap();
let reopened = crate::read(&saved).unwrap();
assert_eq!(reopened.fit_state, doc.fit_state, "the cumulative budget must survive saving");
}
/// A user's custom XML that happens to use our part name is not consumed.
#[test]
fn unrelated_custom_xml_at_the_reserved_fit_name_is_kept() {
let bytes = custom_xml_package("../customxml/wordcraftfit.xml", "customxml/wordcraftfit.xml", b"<other xmlns=\"urn:other\"/>");
let doc = crate::read(&bytes).unwrap();
assert!(doc.fit_state.is_none());
assert!(
doc.passthrough.keys().any(|k| k.eq_ignore_ascii_case("customxml/wordcraftfit.xml")),
"an unrelated store at the reserved name must survive"
);
}
/// When fit_state is set and an unrelated custom XML store uses the reserved name, both survive saving.
#[test]
fn unrelated_custom_xml_at_reserved_name_survives_alongside_fit_state() {
let bytes = custom_xml_package("../customxml/wordcraftfit.xml", "customxml/wordcraftfit.xml", b"<other xmlns=\"urn:other\"/>");
let mut doc = crate::read(&bytes).unwrap();
let fit = wordcraft_doc::FitState { cum_delta: 1.0, cum_space: 0.94, ..Default::default() };
doc.fit_state = Some(fit.clone());
let saved = crate::write(&doc).expect("save must not clash or produce duplicate parts");
let reopened = crate::read(&saved).expect("reopen must succeed");
assert_eq!(reopened.fit_state, Some(fit));
let kept: Vec<_> = reopened
.passthrough
.iter()
.filter(|(k, v)| k.starts_with("customxml/") && String::from_utf8_lossy(v).contains("urn:other"))
.map(|(k, _)| k.clone())
.collect();
assert_eq!(kept.len(), 1, "the unrelated custom XML store must survive saving exactly once: {kept:?}");
assert!(!kept[0].eq_ignore_ascii_case(crate::fit::PART), "it must not take the reserved fit part name");
}
/// The parts the reader carries over unchanged, as one string.
fn unmodelled_roots(d: &wordcraft_doc::Document) -> String {
d.passthrough.get(UNMODELLED_ROOTS).map(|v| String::from_utf8_lossy(v).to_string()).unwrap_or_default()
+5
View File
@@ -54,6 +54,7 @@ pub(crate) struct EmbedWriter<'d> {
}
impl<'d> EmbedWriter<'d> {
/// Creates a new embedded parts writer initialized with default reserved package part paths.
pub fn new(doc: &'d wordcraft_doc::Document) -> EmbedWriter<'d> {
let manifest = doc.passthrough.get(EMBEDDED_PARTS).map(|m| EmbeddedManifest::parse(m)).unwrap_or_default();
let mut taken: HashSet<String> = [
@@ -78,6 +79,10 @@ impl<'d> EmbedWriter<'d> {
if !doc.sources.is_empty() {
taken.extend(["customxml/item1.xml".into(), "customxml/itemprops1.xml".into()]);
}
// The Fit One Page store is written at this path on save. A kept part must not take it.
if doc.fit_state.is_some() {
taken.insert(crate::fit::PART.to_ascii_lowercase());
}
// The macro project's parts keep their names (see `write_as`).
if let Some(m) = doc.passthrough.get(VBA_RELATED) {
for line in String::from_utf8_lossy(m).lines() {
+4
View File
@@ -179,6 +179,10 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
wr.embeds.custom_xml_rels("P", &mut root);
wr.embeds.unmodelled_rels("D", &mut rels);
wr.embeds.unmodelled_rels("P", &mut root);
if let Some(fit) = &doc.fit_state {
rels.add(rt::CUSTOM_XML, &crate::package::relative("word/document.xml", crate::fit::PART), false);
push_part(&mut entries, &mut overrides, crate::fit::PART, crate::fit::write(fit)?, "application/xml", PartRels::default());
}
// Parts of embedded objects and custom XML stores kept from the file.
for (name, bytes, ct, prels) in std::mem::take(&mut wr.embeds.parts) {
push_part(&mut entries, &mut overrides, &name, bytes, &ct, prels);
+1
View File
@@ -224,6 +224,7 @@ Layout|Page Setup|Columns|layout.columns
Layout|Page Setup|Breaks|layout.break
Layout|Page Setup|Line Numbers|layout.lineNumbers
Layout|Page Setup|Hyphenation|layout.hyphenation
Layout|Page Setup|Fit One Page|layout.fitOnePage
Layout|Page Setup|Page Setup Dialog|layout.pageSetup
Layout|Page Setup|Vertical Alignment|layout.verticalAlign
Layout|Page Setup|Document Grid|layout.documentGrid
+5
View File
@@ -10,6 +10,7 @@ use wordcraft_doc::{Block, Pos, StoryRef};
use super::sel_result;
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
/// Returns the command specifications for the Layout tab (page setup, breaks, line numbers).
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("layout.margins", "Margins", "Layout › Page Setup", margins).params(
@@ -39,6 +40,8 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("layout.manualHyphenation", "Manual Hyphenation", "Layout › Page Setup › Hyphenation", manual_hyphenation)
.params(r#"{"from"?: Pos (default: the caret)} → the next word at a line end that could be hyphenated: {"word", "pos", "points": [char index], "suggest": char index} or {"done": true}"#)
.pure(),
CommandSpec::new("layout.fitOnePage", "Fit One Page", "Layout › Page Setup", fit_page::fit_one_page)
.params(r#"{"maxDeltaPt"?: pt (default: 2.0), "stepPt"?: pt (default: the built-in discrete candidate table; when given, up to 20 fine steps), "adjustSpacing"?: bool (default: true)}"#),
CommandSpec::new("layout.pageSetup", "Page Setup", "Layout › Page Setup", |s, v| {
if let Some(props) = v.get("section") {
let mut new: SectionProps = serde_json::from_value(props.clone()).map_err(|e| CmdError::Params(e.to_string()))?;
@@ -765,5 +768,7 @@ fn shifted_by_split(p: &Pos, at: &Pos, new: &Pos) -> Pos {
q
}
mod fit_page;
#[cfg(test)]
mod tests;
File diff suppressed because it is too large Load Diff
+1
View File
@@ -395,6 +395,7 @@ fn check_formatting(s: &Session, id: &str, params: &Value) -> Result<(), CmdErro
|| matches!(
id,
"edit.pasteFormat"
| "layout.fitOnePage"
| "table.shading"
| "table.borders"
| "table.borderPainter"
+1
View File
@@ -13,6 +13,7 @@ use crate::{Session, cmd};
mod bidi;
mod documents;
mod find_apostrophes;
mod fit_one_page;
mod font_sizes;
mod fonts;
mod ime_noop;
File diff suppressed because it is too large Load Diff
+2 -15
View File
@@ -1,7 +1,7 @@
use super::boxes::{BoxInput, BoxLayout, layout_box};
use super::ctx::Ctx;
use super::floats::left_out_objects;
use super::page::{PageBuilder, item_bottom};
use super::page::PageBuilder;
use super::placed::Placed;
use super::{
Block, Blocks, Border, Document, HashMap, HideDeleted, InlineObject, Page, ParaLayout, Paragraph, Rect, StoryRef, TextDirection, fields, para,
@@ -176,20 +176,7 @@ pub(crate) struct NoteBox {
/// The vertical extent of an item laid out in a horizontal frame.
pub(crate) fn v_span(it: &Placed) -> (f32, f32) {
if let Some(r) = it.turned_bounds() {
return (r.y, r.y + r.h);
}
match it {
Placed::Lines { para, l0, l1, y, .. } => (*y, item_bottom(*y, para, *l0, *l1).unwrap_or(*y)),
Placed::Fill { rect, .. }
| Placed::Image { rect, .. }
| Placed::Shape { rect, .. }
| Placed::Graphic { rect, .. }
| Placed::Clip { rect, .. }
| Placed::Cell { rect, .. }
| Placed::Object { rect, .. } => (rect.y, rect.y + rect.h),
Placed::Rule { y0, y1, .. } => (y0.min(*y1), y0.max(*y1)),
}
it.vertical_span()
}
impl NoteBox {
+21
View File
@@ -161,6 +161,27 @@ impl Placed {
Some(turn_rect(*turn, *x, *y, Rect::new(0.0, 0.0, len, last.top + last.height - first.top)))
}
/// The vertical extent `(top, bottom)` the item occupies on its page.
///
/// Items side by side (a table row's cells, say) report overlapping extents; callers that sum
/// them take the union instead.
pub fn vertical_span(&self) -> (f32, f32) {
if let Some(r) = self.turned_bounds() {
return (r.y, r.y + r.h);
}
match self {
Placed::Lines { para, l0, l1, y, .. } => (*y, super::page::item_bottom(*y, para, *l0, *l1).unwrap_or(*y)),
Placed::Fill { rect, .. }
| Placed::Image { rect, .. }
| Placed::Shape { rect, .. }
| Placed::Graphic { rect, .. }
| Placed::Clip { rect, .. }
| Placed::Cell { rect, .. }
| Placed::Object { rect, .. } => (rect.y, rect.y + rect.h),
Placed::Rule { y0, y1, .. } => (y0.min(*y1), y0.max(*y1)),
}
}
/// Move an item laid out in a turned frame (a table cell's text running `turn`) onto the
/// page, the frame's origin landing on page point (`x`, `y`).
pub(crate) fn turn(&mut self, turn: TextDirection, x: f32, y: f32) {
+18
View File
@@ -726,6 +726,12 @@ fn paint_draw_to_review(pen: &Pen, name: &str) -> bool {
pen.text(7.0, 7.0, 8.0, "ab-", c, false);
pen.text(9.0, 14.5, 8.0, "cd", a, false);
}
"fitOnePage" => {
pen.page(4.0, 2.0, 16.0, 18.0);
pen.lines(6.5, 13.5, &[6.0, 8.5, 11.0]);
pen.line_c(&[(10.0, 16.5), (10.0, 13.0)], a);
pen.line_c(&[(8.0, 14.5), (10.0, 13.0), (12.0, 14.5)], a);
}
"position" | "wrapText" => {
pen.lines(3.0, 17.0, &[3.0, 17.0]);
pen.lines(3.0, 6.0, &[7.0, 10.0, 13.0]);
@@ -1738,4 +1744,16 @@ mod tests {
assert!(has_default_highlight_bar("shading"));
assert!(!has_default_highlight_bar("shadingNoBar"));
}
/// Verify that the fitOnePage vector icon renders without panics and emits shapes.
#[test]
fn fit_one_page_icon_paints_without_panic() {
let ctx = egui::Context::default();
let key = "fitOnePage";
let output = ctx.run_ui(egui::RawInput::default(), |ui| {
paint(ui.painter(), Rect::from_min_size(Pos2::ZERO, egui::vec2(20.0, 20.0)), key, Color32::BLACK, Color32::BLUE);
});
assert!(!output.shapes.is_empty(), "{key} icon must emit shapes");
output.drop_without_applying_deltas();
}
}
+1
View File
@@ -292,6 +292,7 @@ fn controls(tab: &str) -> Vec<Control> {
m("Breaks", "layout.break"),
m("Line Numbers", "layout.lineNumbers"),
m("Hyphenation", "layout.hyphenation"),
c("Fit One Page", "layout.fitOnePage"),
c("Position", "arrange.position"),
c("Wrap Text", "arrange.wrap"),
c("Bring Forward", "arrange.bringForward"),
+16
View File
@@ -126,6 +126,22 @@ fn every_keytip_command_is_registered() {
assert!(missing.is_empty(), "unregistered: {missing:#?}");
}
/// layout.fitOnePage has a KeyTip badge on the Layout tab.
#[test]
fn layout_tab_includes_fit_one_page_keytip() {
let a = app();
let (_, cmds) = letters(&a);
let layout_cmds = cmds.iter().find(|(t, _)| t == "Layout").expect("Layout tab exists");
let ctrls = controls("Layout");
let fit_idx = ctrls.iter().position(|c| match &c.target {
Target::Cmd { id, .. } => *id == "layout.fitOnePage",
_ => false,
});
assert!(fit_idx.is_some(), "layout.fitOnePage must be present in Layout controls");
let badge = layout_cmds.1.get(fit_idx.unwrap()).expect("badge exists");
assert!(!badge.is_empty(), "fitOnePage badge must not be empty");
}
#[test]
fn letters_are_unique_inside_every_tab() {
let a = app();
+4
View File
@@ -48,6 +48,7 @@ pub(super) fn columns_items(ui: &mut Ui, app: &mut WordApp) {
mi(ui, app, "More Columns…", "ui.dialog", json!({"name": "columns"}));
}
/// Renders the Layout ribbon tab (Page Setup, Paragraph, Arrange).
pub(super) fn layout(app: &mut WordApp, ui: &mut Ui) {
group(ui, "Page Setup", Some("layout.pageSetup"), app, |ui, app| {
// Text Direction works on what the caret is in: table cells, a text box, else the section.
@@ -119,6 +120,9 @@ pub(super) fn layout(app: &mut WordApp, ui: &mut Ui) {
},
);
});
let fit_resp = big(ui, app, "fitOnePage", "Fit\nOne Page", "layout.fitOnePage", json!({}), false);
let fit_label = tl!("Fit\nOne Page").replace('\n', " ");
fit_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, fit_label.clone()));
});
group(ui, "Paragraph", Some("para.dialog"), app, |ui, app| {
let rp = app.session.doc.para_at(&app.session.sel.focus).map(|_| wordcraft_engine::cmd::para::selection_para(&app.session));
+23
View File
@@ -551,3 +551,26 @@ fn dialog_tabs_expose_the_selected_state_and_report_clicks() {
h.run_steps(2);
assert_eq!(h.state().1, Some(1));
}
/// Fit One Page button renders in the Layout ribbon across light and dark themes.
#[test]
fn layout_fit_one_page_button_renders_in_light_and_dark_themes() {
use egui_kittest::kittest::Queryable;
for theme in [crate::theme::Appearance::Light, crate::theme::Appearance::Dark] {
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
app.ui.tab = "Layout".to_string();
app.ui.theme = theme;
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.run_steps(4);
let button = h.get_by_label("Fit One Page");
assert!(button.rect().width() >= 40.0, "button must have allocated width in {theme:?}");
assert!(button.rect().height() >= 40.0, "button must have allocated height in {theme:?}");
}
}
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -2,7 +2,7 @@
Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.
**509 of 521 catalog features have commands (97.7%).**
**510 of 522 catalog features have commands (97.7%).**
| Tab | Live | Total |
|---|---|---|
@@ -13,7 +13,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
| Insert | 47 | 53 |
| Draw | 10 | 11 |
| Design | 10 | 10 |
| Layout | 22 | 22 |
| Layout | 23 | 23 |
| References | 25 | 25 |
| Mailings | 23 | 23 |
| Review | 29 | 31 |