diff --git a/ROADMAP.md b/ROADMAP.md index d68729c70..38ea04915 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -188,6 +188,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-16 | Equations from Word files keep all their content (#1273): math straight in the body or a table cell, math behind `mc:AlternateContent Requires="m"`, runs inside hyperlinks, simple fields and markup-compatibility blocks within an equation, and `w:sym`/Symbol-font characters in math; operators, delimiters and accents the math font lacks come from a fallback font. Every ECMA-376 §22.1 element is pinned by a coverage test. | | 2026-10-16 | Captions and title pages (#1230): Insert Caption… on the right-click menu of pictures, shapes, text boxes and charts, the caption going under (or over) the object in its own cell or text box; Page Setup's Different First Page and Different Odd & Even Pages check boxes, so a title page can go without its page number | | 2026-10-17 | SECTIONPAGES includes shared continuous-section pages. A native original control confirms a section starting on page 1 and continuing on page 2 reports 2. | +| 2026-10-17 | File › Print's Scale to paper size (#2108): pick Letter, Legal, A4 or any Layout › Size paper and every page is scaled to it and centred, the paper turned to each page's orientation, in the preview, the print job (with the spooler's media) and Save these pages as PDF; the document keeps its page size. `paper` param on `ui.print`/`file.printPdf` | |---|---| | 2026-10-16 | Save Changes gives assistive technology a named alert dialog with the loss warning and any save failure; visual layout and save policy unchanged (#1015). Native screen-reader verification remains pending. | | 2026-10-15 | The font family caches are bounded (4096 families for text, 1024 for math, names over 256 bytes aren't remembered), so a document naming thousands of missing fonts can't grow memory; equation fonts are looked up again after a font is added or removed instead of keeping the old choice (#1354) | @@ -561,6 +562,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-16 | trivial | Progress log: Insert Caption… on objects and Page Setup header and footer options (#1230) | | 2026-10-17 | trivial | SECTIONPAGES includes shared continuous-section pages in body and header/footer fields | | 2026-10-17 | trivial | Footer page tables no longer inflate footer text height; body lines wrap at their physical bounds | +| 2026-10-17 | minor | Progress log: Scale to paper size on File › Print, #2108 | | 2026-10-14 | trivial | Progress log: row table border exceptions | | 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks | | 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged | diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index b55096be2..118ed41b5 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -105,7 +105,7 @@ pub fn specs() -> Vec { .pure(), CommandSpec::new("file.printPdf", "Print to PDF", "File › Print", crate::print::print_pdf_to_file) .params( - r#"{"path": string, "range"?: "all" | "current" | "selection" (the pages the selection touches) | "custom", "pages"?: "1-3,5" (implies custom), "parity"?: "all" | "odd" | "even", "copies"?: 1-999, "collate"?: bool (default true)}"#, + r#"{"path": string, "range"?: "all" | "current" | "selection" (the pages the selection touches) | "custom", "pages"?: "1-3,5" (implies custom), "parity"?: "all" | "odd" | "even", "copies"?: 1-999, "collate"?: bool (default true), "paper"?: "none" | "Letter" | "Legal" | "A4" | … (a Layout › Size name: scale every page to this paper, centred; the document keeps its size)}"#, ) .pure(), CommandSpec::new("file.print", "Print", "File", |s, _| { diff --git a/crates/engine/src/print.rs b/crates/engine/src/print.rs index 9d05060d9..8811e3e50 100644 --- a/crates/engine/src/print.rs +++ b/crates/engine/src/print.rs @@ -127,6 +127,21 @@ pub fn plan(v: &Value, total: usize, current: (usize, usize)) -> Result Result, CmdError> { + match v.get("paper") { + None | Some(Value::Null) => Ok(None), + Some(Value::String(name)) if name.trim().is_empty() || ["none", "document"].contains(&name.trim().to_ascii_lowercase().as_str()) => Ok(None), + Some(Value::String(name)) => wordcraft_geom::paper_by_name(name).map(Some).ok_or_else(|| { + let names: Vec<&str> = wordcraft_geom::PAPER_SIZES.iter().map(|(n, _, _)| *n).collect(); + CmdError::Params(format!("unknown paper `{}`; use none or one of {}", name.chars().take(40).collect::(), names.join(", "))) + }), + Some(_) => Err(CmdError::Params("paper must be a paper size name or none".into())), + } +} + impl Session { /// The pages the caret (or selection) is on, in the output layout: (first, last). pub fn current_pages(&self, lay: &DocLayout) -> (usize, usize) { @@ -143,12 +158,14 @@ impl Session { let markup = crate::cmd::file::export_options(self, v); let lay = self.export_layout_with(markup.markup); let plan = plan(v, lay.pages.len(), self.current_pages(&lay))?; + let paper = paper(v)?.map(|(_, w, h)| (w, h)); let opts = wordcraft_pdf::PdfOptions { pages: Some(plan.pages.clone()), // Repeated pages would repeat the structure tree's entries too. tagged: plan.copies <= 1, include_markup: markup.markup, comments: markup.comments, + paper, ..Default::default() }; let bytes = wordcraft_pdf::export_layout(&self.doc, &lay, &opts).map_err(|e| CmdError::Failed(e.to_string()))?; @@ -217,6 +234,38 @@ mod tests { assert!(r, "printPdf needs a path"); } + /// Scale to paper size (#2108): the PDF goes on the chosen paper; the document keeps its size. + #[test] + fn print_pdf_scales_to_the_chosen_paper() { + // Page dictionaries are written uncompressed: `/MediaBox[0 0 w h]`. + let media = |bytes: &[u8]| { + let raw = String::from_utf8_lossy(bytes).into_owned(); + raw.split("/MediaBox[") + .skip(1) + .filter_map(|r| r.split(']').next()) + .map(|r| r.split_whitespace().filter_map(|n| n.parse::().ok()).collect::>()) + .map(|n| (n[2].round(), n[3].round())) + .collect::>() + }; + let s = Session::new(crate::sample::sample_document()); + let (w, h) = (s.doc.last_section.page_w, s.doc.last_section.page_h); + let rev = s.rev(); + let (own, _) = s.print_pdf(&json!({"range": "current"})).unwrap(); + assert_eq!(media(&own), vec![(w.round(), h.round())]); + for (name, want) in [("A4", (595.0, 842.0)), ("letter", (612.0, 792.0)), ("Legal", (612.0, 1008.0))] { + let (bytes, _) = s.print_pdf(&json!({"range": "current", "paper": name})).unwrap(); + assert_eq!(media(&bytes), vec![want], "{name}"); + } + for none in [json!(null), json!(""), json!("none"), json!("Document")] { + let (bytes, _) = s.print_pdf(&json!({"range": "current", "paper": none})).unwrap(); + assert_eq!(media(&bytes), media(&own)); + } + for bad in [json!("Quarto"), json!(5), json!({"w": 1}), json!("x".repeat(10_000))] { + assert!(s.print_pdf(&json!({"paper": bad})).is_err(), "{bad}"); + } + assert_eq!((s.doc.last_section.page_w, s.doc.last_section.page_h, s.rev()), (w, h, rev), "the document is unchanged"); + } + /// `file.printPdf` writes only inside the automation write root when there is one (#769). #[cfg(not(target_arch = "wasm32"))] #[test] diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 1c8c8590c..cbd7accae 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -296,6 +296,45 @@ pub fn paper_name(w: f32, h: f32) -> Option<&'static str> { .map(|(n, _, _)| *n) } +/// The standard paper size named `name` (case-insensitive), as (name, width, height) in points. +pub fn paper_by_name(name: &str) -> Option<(&'static str, f32, f32)> { + let name = name.trim(); + PAPER_SIZES.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(name)).copied() +} + +/// How a page is placed on other paper when printing with "Scale to paper size" (#2108). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PaperFit { + /// The sheet, turned to the page's orientation (points). + pub paper_w: f32, + pub paper_h: f32, + /// Page points to sheet points. + pub k: f32, + /// Where the page's top-left corner lands on the sheet (the page is centred). + pub dx: f32, + pub dy: f32, +} + +/// Fits a `page_w` × `page_h` page onto `paper_w` × `paper_h` paper: the paper is turned to the +/// page's orientation, the page is scaled up or down by one factor to fill it and centred, as +/// Word's Pages per sheet › Scale to paper size does. Degenerate or non-finite sizes give the +/// page as it is (scale 1 on paper of its own size). +pub fn fit_to_paper(page_w: f32, page_h: f32, paper_w: f32, paper_h: f32) -> PaperFit { + let ok = |v: f32| v.is_finite() && v >= 1.0; + let as_is = PaperFit { paper_w: page_w, paper_h: page_h, k: 1.0, dx: 0.0, dy: 0.0 }; + if !(ok(page_w) && ok(page_h) && ok(paper_w) && ok(paper_h)) { + return as_is; + } + let (short, long) = (paper_w.min(paper_h), paper_w.max(paper_h)); + let (pw, ph) = if page_w > page_h { (long, short) } else { (short, long) }; + let k = (pw / page_w).min(ph / page_h); + let (dx, dy) = ((pw - page_w * k) / 2.0, (ph - page_h * k) / 2.0); + if !(k.is_finite() && k > 0.0 && dx.is_finite() && dy.is_finite()) { + return as_is; + } + PaperFit { paper_w: pw, paper_h: ph, k, dx, dy } +} + #[cfg(test)] mod tests { use super::*; @@ -371,5 +410,37 @@ mod tests { assert_eq!(paper_name(612.0, 792.0), Some("Letter")); assert_eq!(paper_name(792.0, 612.0), Some("Letter")); assert_eq!(paper_name(100.0, 100.0), None); + assert_eq!(paper_by_name(" a4 ").map(|p| p.0), Some("A4")); + assert_eq!(paper_by_name("Legal").map(|p| (p.1, p.2)), Some((612.0, 1008.0))); + assert_eq!(paper_by_name("Quarto"), None); + } + + /// Scale to paper size (#2108): one factor, centred, paper turned to the page's orientation. + #[test] + fn pages_fit_other_paper() { + let close = |a: f32, b: f32| (a - b).abs() < 0.01; + // Letter on A4: narrower paper, so the width decides and the page is centred vertically. + let f = fit_to_paper(612.0, 792.0, 595.28, 841.89); + assert!(close(f.paper_w, 595.28) && close(f.paper_h, 841.89)); + assert!(close(f.k, 595.28 / 612.0) && close(f.dx, 0.0)); + assert!(close(f.dy, (841.89 - 792.0 * f.k) / 2.0) && f.dy > 0.0); + // A4 on Legal: the width decides; A5 on A4 is enlarged. + assert!(close(fit_to_paper(595.28, 841.89, 612.0, 1008.0).k, 612.0 / 595.28)); + assert!(close(fit_to_paper(419.53, 595.28, 595.28, 841.89).k, 595.28 / 419.53)); + // A landscape page gets landscape paper whichever way the paper is given. + let l = fit_to_paper(792.0, 612.0, 595.28, 841.89); + assert!(close(l.paper_w, 841.89) && close(l.paper_h, 595.28)); + assert!(close(l.k, 595.28 / 612.0) && l.dx > 0.0 && close(l.dy, 0.0)); + // The same paper is the page as it is. + let same = fit_to_paper(612.0, 792.0, 612.0, 792.0); + assert!(close(same.k, 1.0) && close(same.dx, 0.0) && close(same.dy, 0.0)); + // Hostile sizes never produce NaN or infinite scales. + for (a, b, c, d) in + [(f32::NAN, 792.0, 595.0, 842.0), (612.0, 0.0, 595.0, 842.0), (612.0, 792.0, f32::INFINITY, 842.0), (612.0, 792.0, -5.0, 842.0)] + { + let f = fit_to_paper(a, b, c, d); + assert_eq!((f.k, f.dx, f.dy), (1.0, 0.0, 0.0)); + } + assert!(fit_to_paper(1.0, 1.0, f32::MAX, f32::MAX).k.is_finite()); } } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index ead0468d8..7e853242e 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -91,11 +91,15 @@ pub struct PdfOptions { pub comments: bool, /// Compress content streams. pub compress: bool, + /// Print on this paper (width, height in points) instead of each page's own size: the page + /// is scaled to fit and centred, the paper turned to the page's orientation (Scale to paper + /// size, #2108). `None` keeps the document's page sizes. + pub paper: Option<(f32, f32)>, } impl Default for PdfOptions { fn default() -> Self { - PdfOptions { pages: None, title: None, author: None, tagged: true, include_markup: false, comments: false, compress: true } + PdfOptions { pages: None, title: None, author: None, tagged: true, include_markup: false, comments: false, compress: true, paper: None } } } @@ -179,19 +183,19 @@ fn write_attempt(doc: &Document, lay: &DocLayout, opts: &PdfOptions, pages: &[us } for &pi in pages { let Some(page) = lay.pages.get(pi) else { return Err(PdfError::BadPage(pi.saturating_add(1))) }; - let fit = Fit::of(page); - let (w, h) = (clamp_side(fit.paper_w), clamp_side(page.h)); - let size = Size::from_wh(w, h).ok_or(PdfError::NoPages)?; + let fit = Fit::of(page, opts.paper); + let size = Size::from_wh(clamp_side(fit.paper_w), clamp_side(fit.paper_h)).ok_or(PdfError::NoPages)?; let mut kp = pdf.start_page_with(PageSettings::new(size)); let mut s = kp.surface(); ex.links.clear(); - // A page with a markup area is scaled down to fit its paper with the area beside it. - let scaled = fit.k < 1.0; + // A page with a markup area is scaled down to fit its paper with the area beside it; a + // print on other paper is scaled to it and centred. + let scaled = fit.k != 1.0 || fit.dx != 0.0 || fit.dy != 0.0; if scaled { - s.push_transform(&Transform::from_row(fit.k, 0.0, 0.0, fit.k, 0.0, fit.dy)); + s.push_transform(&Transform::from_row(fit.k, 0.0, 0.0, fit.k, fit.dx, fit.dy)); } - // `w` is the document page's width: the page colour and watermark stay off the markup area. - ex.page(&mut s, page, w, h); + // `page_w` is the document page's width: the page colour and watermark stay off the markup area. + ex.page(&mut s, page, clamp_side(fit.page_w), clamp_side(page.h)); if scaled { s.pop(); } @@ -224,28 +228,38 @@ fn write_attempt(doc: &Document, lay: &DocLayout, opts: &PdfOptions, pages: &[us } /// How a layout page maps onto its PDF page: a page with a markup area (Print Markup) is scaled -/// by `k` to its paper width and centred vertically (`dy`); any other page is drawn as it is. +/// by `k` to its paper width and centred vertically (`dy`). With [`PdfOptions::paper`] that +/// sheet is then scaled to the chosen paper and centred on it (`dx`, `dy`). Any other page is +/// drawn as it is. #[derive(Clone, Copy, Debug, PartialEq)] struct Fit { + /// The PDF page. paper_w: f32, + paper_h: f32, + /// The document page's width in layout points (without the markup area). + page_w: f32, k: f32, + dx: f32, dy: f32, } impl Fit { - fn of(page: &Page) -> Fit { + fn of(page: &Page, paper: Option<(f32, f32)>) -> Fit { let paper_w = page.w - page.markup; - if page.markup > 0.0 && ok(page.markup) && ok(page.w) && paper_w > 1.0 { + let own = if page.markup > 0.0 && ok(page.markup) && ok(page.w) && paper_w > 1.0 { let k = paper_w / page.w; - Fit { paper_w, k, dy: page.h * (1.0 - k) / 2.0 } + Fit { paper_w, paper_h: page.h, page_w: paper_w, k, dx: 0.0, dy: page.h * (1.0 - k) / 2.0 } } else { - Fit { paper_w: page.w, k: 1.0, dy: 0.0 } - } + Fit { paper_w: page.w, paper_h: page.h, page_w: page.w, k: 1.0, dx: 0.0, dy: 0.0 } + }; + let Some((w, h)) = paper else { return own }; + let f = wordcraft_geom::fit_to_paper(own.paper_w, own.paper_h, w, h); + Fit { paper_w: f.paper_w, paper_h: f.paper_h, page_w: own.page_w, k: own.k * f.k, dx: f.dx, dy: own.dy * f.k + f.dy } } /// A layout point on the PDF page. fn point(self, x: f32, y: f32) -> (f32, f32) { - (x * self.k, y * self.k + self.dy) + (x * self.k + self.dx, y * self.k + self.dy) } } @@ -1448,7 +1462,7 @@ impl Exporter<'_> { _ => None, }) .unwrap_or(c.x); - let (x, y) = self.lay.pages.get(c.page).map(|p| Fit::of(p).point(x, c.top)).unwrap_or((x, c.top)); + let (x, y) = self.lay.pages.get(c.page).map(|p| Fit::of(p, self.opts.paper).point(x, c.top)).unwrap_or((x, c.top)); return Some((*out, x, y)); } self.locate(pos.story, &pos.path) @@ -1461,7 +1475,7 @@ impl Exporter<'_> { let Some(out) = self.out_index.get(pi) else { continue }; let Some(page) = self.lay.pages.get(*pi) else { continue }; let (x, y) = match page.items.get(*ii) { - Some(Placed::Lines { x, y, .. }) => Fit::of(page).point(*x, *y), + Some(Placed::Lines { x, y, .. }) => Fit::of(page, self.opts.paper).point(*x, *y), _ => (0.0, 0.0), }; return Some((*out, x, y)); @@ -1521,7 +1535,7 @@ impl Exporter<'_> { if title.is_empty() { continue; } - let (x, y) = Fit::of(page).point(*x, *y + line.top - piece.top); + let (x, y) = Fit::of(page, self.opts.paper).point(*x, *y + line.top - piece.top); flat.push((level.min(8), title, out, x, y)); } } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index bed61ce78..d112eeb36 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -1809,3 +1809,54 @@ fn image_budget_is_checked_from_the_header() { assert_eq!(dims, (30_000, 30_000)); assert!(load_image(&Arc::new(hdr)).is_none()); } + +/// Scale to paper size (#2108): every page goes on the chosen paper, turned to the page's +/// orientation, with its text, links and Print Markup's balloons scaled onto it. +#[test] +fn pages_print_scaled_to_other_paper() { + let size = |bytes: &[u8]| { + let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).expect("parse"); + pdf.pages().iter().map(|p| p.media_box()).map(|r| (r.width().round(), r.height().round())).collect::>() + }; + let a4 = Some((595.28, 841.89)); + let d = sample(); + let plain = export(&d, &PdfOptions::default()).unwrap(); + assert!(size(&plain).iter().all(|s| *s == (612.0, 792.0)), "the sample is Letter"); + let on_a4 = export(&d, &PdfOptions { paper: a4, compress: false, ..Default::default() }).unwrap(); + assert!(size(&on_a4).iter().all(|s| *s == (595.0, 842.0)), "{:?}", size(&on_a4)); + assert_eq!(size(&on_a4).len(), size(&plain).len(), "the pagination is the document's"); + assert_eq!(squash(&extract_text(&on_a4).concat()), squash(&extract_text(&plain).concat())); + // Link areas are scaled with the page: they stay on the narrower paper. + let raw = String::from_utf8_lossy(&on_a4).into_owned(); + let rects: Vec> = raw + .split("/Rect[") + .skip(1) + .filter_map(|r| r.split(']').next()) + .map(|r| r.split_whitespace().filter_map(|n| n.parse().ok()).collect()) + .collect(); + assert!(!rects.is_empty(), "the sample has links"); + for r in &rects { + assert!(r.len() == 4 && r.iter().all(|v| (0.0..=842.0).contains(v)) && r[0] <= 595.28 && r[2] <= 595.28, "{r:?}"); + } + // A landscape page gets landscape paper; Legal is taller than Letter. + let mut wide = Document::from_text("Wide page"); + let s = &mut wide.last_section; + (s.page_w, s.page_h) = (792.0, 612.0); + let landscape = export(&wide, &PdfOptions { paper: a4, ..Default::default() }).unwrap(); + assert_eq!(size(&landscape), vec![(842.0, 595.0)]); + let legal = export(&Document::from_text("Tall"), &PdfOptions { paper: Some((612.0, 1008.0)), ..Default::default() }).unwrap(); + assert_eq!(size(&legal), vec![(612.0, 1008.0)]); + // Print Markup's page with balloons goes on the same paper. + let mut c = Document::from_text("Quarterly figures are in."); + let part = c.add_part(wordcraft_doc::PartKind::Comment, vec![para_block(Paragraph::with_text("Check the totals", CharProps::default()))]); + c.comments.insert(0, wordcraft_doc::Comment { author: "Ada".into(), part, ..Default::default() }); + c.insert_object(&wordcraft_doc::Pos::body(0, 4), InlineObject::CommentStart { id: 0 }, &CharProps::default()).unwrap(); + let marked = export(&c, &PdfOptions { comments: true, include_markup: true, paper: a4, ..Default::default() }).unwrap(); + assert_eq!(size(&marked), vec![(595.0, 842.0)]); + assert!(squash(&extract_text(&marked).concat()).contains("Check the totals")); + // Hostile paper never panics: it prints the page as it is. + for paper in [(f32::NAN, 1.0), (0.0, 0.0), (-5.0, f32::INFINITY)] { + let bytes = export(&d, &PdfOptions { paper: Some(paper), ..Default::default() }).unwrap(); + assert_eq!(size(&bytes), size(&plain)); + } +} diff --git a/crates/ui-egui/src/print_page.rs b/crates/ui-egui/src/print_page.rs index 490b73868..9d093887f 100644 --- a/crates/ui-egui/src/print_page.rs +++ b/crates/ui-egui/src/print_page.rs @@ -29,6 +29,9 @@ pub struct PrintSettings { pub orientation: String, /// The preview's zoom (1.0 is the page's real size), or `None` to fit the whole page. pub zoom: Option, + /// Scale to paper size (#2108): `none` prints the document's page sizes, a Layout › Size + /// name scales every page onto that paper. The document itself is unchanged. + pub paper: String, } impl Default for PrintSettings { @@ -42,6 +45,7 @@ impl Default for PrintSettings { preview: 0, zoom: None, orientation: "auto".into(), + paper: NO_SCALING.into(), } } } @@ -59,12 +63,22 @@ impl PrintSettings { if self.range == "custom" { v["pages"] = json!(self.pages); } + if self.paper != NO_SCALING { + v["paper"] = json!(self.paper); + } v } + + /// The paper pages are scaled to, as (width, height) in points, or `None` for their own size. + pub fn paper_size(&self) -> Option<(f32, f32)> { + wordcraft_geom::paper_by_name(&self.paper).map(|(_, w, h)| (w, h)) + } } const RANGES: [(&str, &str); 4] = [("all", "All pages"), ("current", "Current page"), ("selection", "Selected pages"), ("custom", "Custom range")]; const PARITIES: [(&str, &str); 3] = [("all", "All pages in the range"), ("odd", "Odd pages only"), ("even", "Even pages only")]; +/// The Scale to paper size choice that keeps each page's own size. +const NO_SCALING: &str = "none"; const ORIENTATIONS: [(&str, &str); 3] = [("auto", "Auto orientation"), ("portrait", "Portrait"), ("landscape", "Landscape")]; /// A choice list over `(id, label)` pairs; true when the choice changed. @@ -198,6 +212,11 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { choice(ui, "print_parity", &mut app.ui.print.parity, &PARITIES); ui.label(egui::RichText::new(tl!("Orientation")).font(medium(13.0))); choice(ui, "print_orientation", &mut app.ui.print.orientation, &ORIENTATIONS); + // Word: Pages per sheet › Scale to paper size. Only the printout changes. + ui.label(egui::RichText::new(tl!("Scale to paper size")).font(medium(13.0))); + let papers: Vec<(&str, &str)> = + std::iter::once((NO_SCALING, "No Scaling")).chain(wordcraft_geom::PAPER_SIZES.iter().map(|(n, _, _)| (*n, *n))).collect(); + choice(ui, "print_paper", &mut app.ui.print.paper, &papers); match wordcraft_engine::print::plan(&app.ui.print.params(), total, current) { Ok(plan) => { @@ -242,7 +261,10 @@ fn preview(app: &mut WordApp, ui: &mut Ui, lay: &DocLayout, total: usize) { let t = Tokens::get(ui.ctx()); let ctx = ui.ctx().clone(); let page = app.ui.print.preview; - let (pw, ph) = lay.pages.get(page).map_or((612.0, 792.0), |p| (p.w.max(1.0), p.h.max(1.0))); + let (page_w, page_h) = lay.pages.get(page).map_or((612.0, 792.0), |p| (p.w.max(1.0), p.h.max(1.0))); + // Scale to paper size: the sheet is the chosen paper with the page scaled and centred on it. + let sheet = app.ui.print.paper_size().map(|(w, h)| wordcraft_geom::fit_to_paper(page_w, page_h, w, h)); + let (pw, ph) = sheet.map_or((page_w, page_h), |f| (f.paper_w, f.paper_h)); let pane = vec2(ui.available_width().max(120.0), (ui.available_height() - PREVIEW_BAR).max(120.0)); // Screen points per page point. let fit = ((pane.x - 2.0 * PREVIEW_MARGIN) / pw).min((pane.y - 2.0 * PREVIEW_MARGIN) / ph).max(0.01); @@ -257,8 +279,9 @@ fn preview(app: &mut WordApp, ui: &mut Ui, lay: &DocLayout, total: usize) { resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Image, true, &label)); ui.painter().rect_filled(r.translate(vec2(2.0, 3.0)), 0.0, t.page_shadow); ui.painter().rect(r, 0.0, egui::Color32::WHITE, Stroke::new(1.0, t.border_strong), egui::StrokeKind::Inside); - if let Some(tex) = page_texture(app, &ctx, lay, page, size.x) { - ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), egui::Color32::WHITE); + let on_sheet = sheet.map_or(r, |f| Rect::from_min_size(r.min + vec2(f.dx, f.dy) * scale, vec2(page_w, page_h) * f.k * scale)); + if let Some(tex) = page_texture(app, &ctx, lay, page, on_sheet.width()) { + ui.painter().image(tex.id(), on_sheet, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), egui::Color32::WHITE); } }); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/printing.rs b/crates/ui-egui/src/printing.rs index 24acb80d7..805eabf78 100644 --- a/crates/ui-egui/src/printing.rs +++ b/crates/ui-egui/src/printing.rs @@ -9,7 +9,7 @@ pub type Submit = Box, Settings, bool, Vec<(String, String)>) -> pub fn commands(app: &crate::WordApp) -> Vec { use serde_json::json; let enabled = if app.services.print_native.is_some() { app.printing.ready() } else { app.services.print.is_some() }; - let submit_params = "range?: all|current|selection|custom; pages?: string (1-3,5); parity?: all|odd|even; copies?: integer 1..999; collate?: bool; orientation?: auto|portrait|landscape. Native uses the selected printer, duplex and grayscale from ui.printSettings; omitted copies/collation use current settings. Browser printing opens its dialog. Submits only when explicitly executed."; + let submit_params = "range?: all|current|selection|custom; pages?: string (1-3,5); parity?: all|odd|even; copies?: integer 1..999; collate?: bool; orientation?: auto|portrait|landscape; paper?: none|Letter|Legal|A4|… (Layout › Size names: Scale to paper size, every page scaled onto that paper and centred; the document is unchanged). Native uses the selected printer, duplex and grayscale from ui.printSettings; omitted copies/collation use current settings. Browser printing opens its dialog. Submits only when explicitly executed."; vec![ json!({"id":"ui.printSettings", "label":"Print settings", "location":"File › Print", "shortcut":null, "params":"printer?: discovered name; copies?: integer 1..999; duplex?: printerDefault|off|longEdge|shortEdge|null; grayscale?: bool; refresh?: bool. Empty params inspect printer/copies/duplex/grayscale, printers, busy/discovering/ready and status/detail. Updates are atomic; refresh discovers only on native hosts. Never submits or opens a dialog.", @@ -114,6 +114,25 @@ pub fn feedback(message: &Feedback, translate: impl Fn(&str) -> String) -> Strin } } +/// The IPP/PWG 5101.1 media name of a Layout › Size paper (the spooler's `media` option). +pub fn pwg_media(paper: &str) -> Option<&'static str> { + Some(match paper { + "Letter" => "na_letter_8.5x11in", + "Legal" => "na_legal_8.5x14in", + "Executive" => "na_executive_7.25x10.5in", + "A3" => "iso_a3_297x420mm", + "A4" => "iso_a4_210x297mm", + "A5" => "iso_a5_148x210mm", + "B4 (JIS)" => "jis_b4_257x364mm", + "B5 (JIS)" => "jis_b5_182x257mm", + "Tabloid" => "na_ledger_11x17in", + "Statement" => "na_invoice_5.5x8.5in", + "Envelope #10" => "na_number-10_4.125x9.5in", + "Envelope DL" => "iso_dl_110x220mm", + _ => return None, + }) +} + /// Build the upstream page-range/markup PDF and hand it to the chosen host print flow. pub fn submit(app: &mut crate::WordApp, params: &serde_json::Value) -> Result { use serde_json::json; @@ -166,6 +185,10 @@ pub fn submit(app: &mut crate::WordApp, params: &serde_json::Value) -> Result p.h) }; + // Scale to paper size: ask the printer for that paper too, so it takes it from the right tray. + if let Some(media) = wordcraft_engine::print::paper(params).ok().flatten().and_then(|(name, _, _)| pwg_media(name)) { + print_options.push(("media".into(), media.into())); + } if is_landscape { print_options.push(("orientation-requested".into(), "4".into())); print_options.push(("landscape".into(), "true".into())); @@ -192,3 +215,16 @@ pub fn submit(app: &mut crate::WordApp, params: &serde_json::Value) -> Result = + raw.split("/MediaBox[").nth(1).and_then(|r| r.split(']').next()).unwrap().split_whitespace().map(|n| n.parse().unwrap()).collect(); + (n[2].round(), n[3].round()) + }; + + // The Print page's choice reaches the job. + assert!(app.ui.print.params().get("paper").is_none(), "No Scaling by default"); + app.ui.print.paper = "Legal".into(); + let params = app.ui.print.params(); + assert_eq!(params["paper"], "Legal"); + app.run("ui.printSubmit", params).unwrap(); + { + let jobs = jobs.borrow(); + let (bytes, _, _, options) = jobs.last().unwrap(); + assert_eq!(media_box(bytes), (612.0, 1008.0)); + assert!(options.contains(&("media".to_owned(), "na_legal_8.5x14in".to_owned())), "{options:?}"); + } + app.printing.poll(&egui::Context::default()); + app.poll_printing(&egui::Context::default()); + + app.run("ui.printSubmit", json!({"paper": "A4"})).unwrap(); + assert_eq!(media_box(&jobs.borrow().last().unwrap().0), (595.0, 842.0)); + app.printing.poll(&egui::Context::default()); + app.poll_printing(&egui::Context::default()); + + // An unknown paper submits nothing. + let before = jobs.borrow().len(); + assert!(app.run("ui.printSubmit", json!({"paper": "Quarto"})).is_err()); + assert_eq!(jobs.borrow().len(), before); + assert_eq!((app.session.doc.last_section.page_w, app.session.doc.last_section.page_h), page, "the document is unchanged"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b2585e482..f85b7587c 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -112,7 +112,10 @@ and check `status`/`detail`. The browser keeps its own print hook. PDF export re `parity`, `copies`, `collate`). Native printing renders the selected pages once and passes copies/collation to the spooler; the browser and `file.printPdf` keep copies in the PDF. Omitting native `copies` uses the configured `ui.printSettings` value. `collate` defaults to -File › Print's setting; invalid ranges submit no job. +File › Print's setting; invalid ranges submit no job. `paper` (`none`, or a Layout › Size name +such as `Letter`, `Legal`, `A4`) is Scale to paper size (#2108): every page is scaled onto that +paper and centred, and native jobs ask the spooler for that `media`; the document's page size is +unchanged. `file.printPdf` takes it too; an unknown paper submits no job. `engine.commands {"search":"print"}` lists the engine print commands and the app-owned `ui.printSettings`, `ui.printSubmit`, and legacy `ui.print` alias, with current enablement diff --git a/docs/gaps.md b/docs/gaps.md index 93527988e..355f717ef 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -39,7 +39,7 @@ Active footnote and endnote text stays readable while editing (#2545). This fixe | 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | SECTIONPAGES now counts shared continuous-section pages in body and header/footer fields; legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; Arial Unicode MS no longer selects monospace substitutes | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) | | 2 | **Word's native ink is not drawn from its strokes**; OLE objects show only their picture | B | Every native ink form is preserved: drawing-level ink with its optional picture fallback, direct run-level content parts, and ink members of groups and drawing canvases (kept in their place among the members, drawn from their fallback picture). Drawing canvases are read and written (#344); group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Pen notes from Word show only as their picture and can't be edited; OLE objects can't be opened | 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) | | 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; Track Changes Options has the Moves rows (Moved from / Moved to mark and colour) and `w:doNotTrackMoves` (Track moves off: a cut and paste is a deletion and an insertion) round-trips; still missing: drag-and-drop moves (no text drag yet). Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes and table/section changes made in WordCraft. Opaque custom XML backing stores now survive saves; binding evaluation remains absent. Replace All now checks content-control locks before editing any match; a refused batch preserves the document and undo history. | B | List-label pitch corrected in synthetic metric controls; Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) | -| 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641); driver tray/options and pages per sheet remain; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) | +| 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641) and scales pages to another paper size (#2108); driver tray/options and pages per sheet remain; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) | | 7 | **Dialog depth**: 59 modal dialogs vs Word's ~100 (Drop Cap Options matches Word's menu and dialog; Split Cells and Convert Text to Table match Word's observed limits and defaults) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs`; Cell Margins (#1960) now opens with current defaults and applies only confirmed edits | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) | | 9 | **Password-protected `.doc` files** (AES) can't be opened; password-protected `.docx` opens and saves (agile encryption, #55 closed by #281) | | `crates/docx/src/crypt.rs`; `crates/docbin/src/lib.rs` opens XOR/RC4/RC4 CryptoAPI `.doc` with a password (#1079, untested on Word-written files; AES `.doc` refused) | Corporate and legal users with old files | 3–5 | [file-format-parity.md](file-format-parity.md) | | 8 | **Proofing covers English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech and Indonesian only**; no dictionaries, grammar or hyphenation for the other ~42 languages Word proofs (text in them is left unmarked) | | `crates/proof` `ProofLang::PROOFED`: English, Polish, Portuguese (pt-BR via the installed VERO dictionary, #797), Italian (it-IT via the installed Hunspell dictionary), Croatian (hr-HR, 2026-10-13), Turkish (tr_TR spelling, grammar, TDK hyphenation, 2026-10-13) Russian (the installed `ru_RU` dictionary, 2026-10-12) and Indonesian (the installed `id_ID` dictionary, #498, 2026-10-16), a language per run; Bulgarian has hyphenation only (#826); open PRs add Danish (#723), Russian (#894) and Czech (#1048); Word ships 120 proofing tools; issues #25, #40, #100 open | Every writer outside English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech and Indonesian | 20–40 | [localization-parity.md](localization-parity.md) | @@ -139,6 +139,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-16 | trivial | An otherwise empty section at page start retains its line before continuous content; later closing marks stay quiet | | 2026-10-17 | trivial | SECTIONPAGES includes shared continuous-section pages in body and header/footer fields | | 2026-10-17 | trivial | Footer page tables preserve text height and wrap intersecting body lines | +| 2026-10-17 | minor | File › Print's Scale to paper size (#2108): pick Letter, Legal, A4 or any Layout › Size paper and every page is scaled to it and centred, the paper turned to each page's orientation, in the preview, the print job (with the spooler's media) and Save these pages as PDF; the document keeps its page size. `paper` param on `ui.print`/`file.printPdf` | | 2026-10-14 | trivial | Row table border exceptions fixed; other row table exceptions remain unsupported | | 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks | | 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 4b1dc03e1..5aeb7d516 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -12,7 +12,7 @@ pens, microphones (Dictate) and speakers (Read Aloud), and displays. | Hardware | Word | WordCraft macOS | Windows | Linux / BSD | Web | Parity | Hours | |---|---|---|---|---|---|---|---| -| Printers: native print dialog, printer choice, copies, duplex, page ranges, tray, scaling | ✅ (`WordPDE.plugin` print-dialog extension on Mac) | 🟡 Direct CUPS printing; printer, copies, collation, duplex, ranges and grayscale | 🟡 Direct spooler printing; printer, copies, collation, duplex, ranges and grayscale | 🟡 Direct spooler printing; printer, copies, collation, duplex, ranges and grayscale | ✅ browser print dialog (#209) | 35% | 5–8 | +| Printers: native print dialog, printer choice, copies, duplex, page ranges, tray, scaling | ✅ (`WordPDE.plugin` print-dialog extension on Mac) | 🟡 Direct CUPS printing; printer, copies, collation, duplex, ranges, grayscale and scale to paper size | 🟡 Direct spooler printing; printer, copies, collation, duplex, ranges, grayscale and scale to paper size | 🟡 Direct spooler printing; printer, copies, collation, duplex, ranges, grayscale and scale to paper size | ✅ browser print dialog (#209) | 35% | 5–8 | | GPU rendering, HiDPI/Retina, pixel-aligned text | ✅ | ✅ wgpu + vello_cpu rasteriser, whole-pixel text (#110, #165) | ✅ DirectX 12 default (#50), OpenGL when DirectX 12 has no GPU driver (#316) or on 32-bit; startup crash on Intel UHD (#170) | ✅ (soft text on KDE at some zooms, #140) | ✅ | 80% | 3–5 (bugs) | | Pen / stylus: ink, pressure, eraser, ink to shape/math | ✅ (Draw tab, `InkRender.bundle`) | 🟡 ink, stroke eraser, Ink to Shape incl. one- and two-stroke arrows with open or closed heads (#307, #395); pressure and Ink to Math missing | 🟡 same | 🟡 same | 🟡 same | 50% | (Draw tab, 15–25) | | Multiple monitors, per-monitor DPI, remembered window position, a window that fits its screen | ✅ | ✅ (#38, #76, #1215) | ✅ (winit DPI fix pending, #155) | ✅ | — | 85% | 1 | @@ -26,6 +26,7 @@ pens, microphones (Dictate) and speakers (Read Aloud), and displays. | Date | Change | Summary | |---|---|---| | 2026-10-15 | trivial | Pen / stylus row: Ink to Shape also makes two-stroke arrows and arrows with a closed triangle head | +| 2026-10-17 | minor | File › Print's Scale to paper size (#2108): pick Letter, Legal, A4 or any Layout › Size paper and every page is scaled to it and centred, the paper turned to each page's orientation, in the preview, the print job (with the spooler's media) and Save these pages as PDF; the document keeps its page size. `paper` param on `ui.print`/`file.printPdf` | | 2026-10-14 | trivial | Pen / stylus row corrected: ink, eraser and Ink to Shape (with arrows) work; pressure and Ink to Math missing | | 2026-10-13 | trivial | A window too big for its screen shrinks to fit when it opens (#1215): eframe sized the first window to the whole monitor, frame and taskbar not counted, so on a laptop screen its bottom, with the end of the scroll bar, was past the screen edge. A window saved that size is fitted on its next launch too | | 2026-10-12 | trivial | 32-bit Windows starts on OpenGL: DirectX 12 graphics pipeline creation returns "The parameter is incorrect" and the app exited before the window appeared |