mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-14 12:24:20 +00:00
Merge pull request #6436 from batistalucasv/feat/fit-one-page-upstream
Layout: add Fit One Page (#6433)
This commit is contained in:
+2
-1
@@ -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) |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ mod bibliography;
|
||||
mod chart_spec;
|
||||
mod crypt;
|
||||
mod custom;
|
||||
mod fit;
|
||||
mod flat;
|
||||
mod fonts;
|
||||
mod package;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,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 |
|
||||
|
||||
Reference in New Issue
Block a user