M-print: File > Print preview, page range, odd/even, copies and collate (#641) (#674)

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein 2026-10-12 05:30:58 +02:00 committed by GitHub
parent 39f51ca292
commit 381c475edc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 685 additions and 42 deletions

View File

@ -132,6 +132,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| Date | What landed |
| 2026-10-11 | Fonts: Palatino Linotype and Book Antiqua fall back to Palatino (macOS) or TeX Gyre Pagella / P052 / URW Palladio (Linux) before generic serif (#670) |
|---|---|
| 2026-10-11 | File › Print: page preview, page range (all, current, selected, custom 1-3,5), odd/even, copies and collate go into the printed PDF (`file.printPdf`, `ui.print` params); printer, duplex and pages per sheet are still the system dialog's (#641, stage 1) |
| 2026-10-11 | A vertically merged table cell (eraser on a horizontal border) lays out, shows and clicks as one cell spanning its rows (#640) |
| 2026-10-11 | Word XML files (#548): Word XML Document (Flat OPC `.xml`) opens and saves through the DOCX reader and writer; Word 2003 XML opens (styles, lists, tables, sections, notes, comments, pictures, hyperlinks) |
| 2026-10-11 | Font fallback indexes which characters each installed font maps, reading each font file once (#605): text in a script no installed font covers no longer re-reads every system font per distinct character (200 such characters: 13 s → 0.2 s) |

View File

@ -54,6 +54,11 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("file.exportPng", "Export Page as PNG", "File › Export", export_png)
.params(r#"{"path": string, "page"?: n (1-based), "scale"?: px per pt}"#)
.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)}"#,
)
.pure(),
CommandSpec::new("file.print", "Print", "File", |s, _| {
s.ui_requests.push(json!({"open": "print"}));
sel_result(s)

View File

@ -12,6 +12,7 @@ pub mod io;
mod io_ext;
pub mod keymap;
pub mod math_gallery;
pub mod print;
pub mod ribbon;
pub mod sample;
mod session;

214
crates/engine/src/print.rs Normal file
View File

@ -0,0 +1,214 @@
//! Print settings (#641): which pages go out, how many copies, as a PDF.
//!
//! WordCraft prints through a PDF (the host opens it in the system's print flow), so the settings
//! that can be delivered are the ones that shape the PDF: the page range, odd/even pages, copies
//! and collation. Printer choice, duplex and pages per sheet belong to the OS print dialog.
//!
//! Page ranges and copies come from users and control channels, so they are bounded: a range
//! text is at most [`MAX_SPEC_CHARS`] long, copies are clamped to [`MAX_COPIES`], and the
//! resulting page list never exceeds [`MAX_PRINT_PAGES`].
use serde_json::{Value, json};
use wordcraft_layout::DocLayout;
use crate::{CmdError, Session, p};
/// Longest page-range text accepted.
pub const MAX_SPEC_CHARS: usize = 500;
/// Most copies one print job asks for.
pub const MAX_COPIES: u32 = 999;
/// Most pages (counting copies) one print job holds.
pub const MAX_PRINT_PAGES: usize = 5000;
/// Parses a custom page range like `1-3, 5, 8-` into 0-based page indexes, in the order typed
/// (Word prints them in document order; so does this: sorted, without repeats). Pages are
/// 1-based in the text; `8-` means "to the last page" and `-3` "from the first". An empty text,
/// page 0, a page past `total`, a reversed range or anything else is an error.
pub fn parse_page_range(spec: &str, total: usize) -> Result<Vec<usize>, String> {
if spec.chars().count() > MAX_SPEC_CHARS {
return Err("the page range is too long".into());
}
if total == 0 {
return Err("the document has no pages".into());
}
let mut keep = vec![false; total];
let mut any = false;
for item in spec.split([',', ';']) {
let item: String = item.chars().filter(|c| !c.is_whitespace()).collect();
if item.is_empty() {
continue;
}
let page = |t: &str| -> Result<usize, String> {
let n: usize = t.parse().map_err(|_| format!("`{item}` isn't a page or a range like 2-4"))?;
if n == 0 || n > total {
return Err(format!("page {n} doesn't exist; the document has {total} page{}", if total == 1 { "" } else { "s" }));
}
Ok(n)
};
let (from, to) = match item.split_once('-') {
None => {
let n = page(&item)?;
(n, n)
}
Some(("", "")) => return Err("`-` isn't a page or a range like 2-4".into()),
Some((a, b)) => {
let from = if a.is_empty() { 1 } else { page(a)? };
let to = if b.is_empty() { total } else { page(b)? };
if from > to {
return Err(format!("`{item}` runs backwards"));
}
(from, to)
}
};
for flag in keep.iter_mut().take(to).skip(from - 1) {
*flag = true;
}
any = true;
}
if !any {
return Err("type the pages to print, like 1-3, 5".into());
}
Ok(keep.iter().enumerate().filter(|(_, k)| **k).map(|(i, _)| i).collect())
}
/// What a print job holds: the pages (0-based, copies included, in print order).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PrintPlan {
/// Page indexes in print order, repeated for copies.
pub pages: Vec<usize>,
/// Distinct pages chosen (before copies).
pub chosen: usize,
pub copies: u32,
}
/// Works out the print plan for `v` against a layout of `total` pages whose caret and selection
/// sit on pages `current` (first, last). Params:
/// `range` (`all` | `current` | `selection` | `custom`; `custom` is implied by `pages`),
/// `pages` (text like `1-3,5`), `parity` (`all` | `odd` | `even`), `copies` (1–999),
/// `collate` (default true: 1,2,3 1,2,3; false: 1,1,2,2,3,3).
pub fn plan(v: &Value, total: usize, current: (usize, usize)) -> Result<PrintPlan, CmdError> {
let bad = |m: String| CmdError::Params(m);
let pages_text = p::str(v, "pages").filter(|t| !t.trim().is_empty());
let range = p::str(v, "range").unwrap_or(if pages_text.is_some() { "custom" } else { "all" });
let last = total.saturating_sub(1);
let mut pages: Vec<usize> = match range {
"all" => (0..total).collect(),
"current" => vec![current.0.min(last)],
"selection" => (current.0.min(last)..=current.1.min(last).max(current.0.min(last))).collect(),
"custom" => parse_page_range(pages_text.unwrap_or(""), total).map_err(bad)?,
other => return Err(bad(format!("unknown range `{other}`; use all, current, selection or custom"))),
};
match p::str(v, "parity").unwrap_or("all") {
"all" => {}
// Page numbers are 1-based: the odd ones are indexes 0, 2, 4…
"odd" => pages.retain(|i| i % 2 == 0),
"even" => pages.retain(|i| i % 2 == 1),
other => return Err(bad(format!("unknown parity `{other}`; use all, odd or even"))),
}
if pages.is_empty() {
return Err(bad("no pages to print with these settings".into()));
}
let copies = p::u64(v, "copies").unwrap_or(1).clamp(1, u64::from(MAX_COPIES)) as u32;
let chosen = pages.len();
let total_out = chosen.checked_mul(copies as usize).filter(|n| *n <= MAX_PRINT_PAGES);
let Some(total_out) = total_out else {
return Err(bad(format!("{chosen} pages × {copies} copies is more than the {MAX_PRINT_PAGES} pages one print job can hold")));
};
let mut out = Vec::with_capacity(total_out);
if p::bool(v, "collate").unwrap_or(true) {
for _ in 0..copies {
out.extend_from_slice(&pages);
}
} else {
for &pg in &pages {
out.extend(std::iter::repeat_n(pg, copies as usize));
}
}
Ok(PrintPlan { pages: out, chosen, copies })
}
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) {
let (a, b) = self.sel.ordered();
let first = lay.caret_on(&a, self.page_hint).map_or(0, |c| c.page);
let last = lay.caret_on(&b, self.page_hint).map_or(first, |c| c.page);
(first, last.max(first))
}
/// The document as the PDF of a print job `v` describes (see [`plan`]), with the number of
/// distinct pages chosen and copies. Does not touch the document.
pub fn print_pdf(&self, v: &Value) -> Result<(Vec<u8>, PrintPlan), CmdError> {
let lay = self.export_layout();
let plan = plan(v, lay.pages.len(), self.current_pages(&lay))?;
let opts = wordcraft_pdf::PdfOptions {
pages: Some(plan.pages.clone()),
// Repeated pages would repeat the structure tree's entries too.
tagged: plan.copies <= 1,
..Default::default()
};
let bytes = wordcraft_pdf::export_layout(&self.doc, &lay, &opts).map_err(|e| CmdError::Failed(e.to_string()))?;
Ok((bytes, plan))
}
}
/// `file.printPdf`: write the print job's PDF to `path` (no dialogs).
pub(crate) fn print_pdf_to_file(s: &mut Session, v: &Value) -> Result<Value, CmdError> {
let path = p::req_str(v, "path")?;
let path = if path.to_ascii_lowercase().ends_with(".pdf") { path.to_string() } else { format!("{path}.pdf") };
let (bytes, plan) = s.print_pdf(v)?;
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(&path, &bytes).map_err(|e| CmdError::Failed(format!("{path}: {e}")))?;
Ok(json!({"path": path, "pages": plan.pages.len(), "chosen": plan.chosen, "copies": plan.copies, "bytes": bytes.len()}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ranges_parse_and_hostile_ones_are_errors() {
assert_eq!(parse_page_range("1-3, 5", 6), Ok(vec![0, 1, 2, 4]));
assert_eq!(parse_page_range("5,1-2,2", 6), Ok(vec![0, 1, 4]), "document order, no repeats");
assert_eq!(parse_page_range("4-", 6), Ok(vec![3, 4, 5]));
assert_eq!(parse_page_range("-2", 6), Ok(vec![0, 1]));
for bad in ["", " , ", "0", "7", "3-1", "a", "1-2-3", "99999999999999999999999", "-", "1..3", &"1,".repeat(400)] {
assert!(parse_page_range(bad, 6).is_err(), "`{bad}` is refused");
}
assert!(parse_page_range("1", 0).is_err());
}
#[test]
fn plans_apply_range_parity_copies_and_caps() {
let p = |v: Value| plan(&v, 6, (2, 3)).map(|p| p.pages);
assert_eq!(p(json!({})), Ok(vec![0, 1, 2, 3, 4, 5]));
assert_eq!(p(json!({"range": "current"})), Ok(vec![2]));
assert_eq!(p(json!({"range": "selection"})), Ok(vec![2, 3]));
assert_eq!(p(json!({"pages": "2-4", "copies": 2})), Ok(vec![1, 2, 3, 1, 2, 3]));
assert_eq!(p(json!({"pages": "2-3", "copies": 2, "collate": false})), Ok(vec![1, 1, 2, 2]));
assert_eq!(p(json!({"parity": "even"})), Ok(vec![1, 3, 5]));
assert!(p(json!({"range": "custom"})).is_err(), "custom needs pages");
assert!(p(json!({"range": "everything"})).is_err());
assert!(p(json!({"range": "current", "parity": "even"})).is_err(), "page 3 is odd: nothing left");
// Hostile copies are clamped; a job that would be too big is refused, not allocated.
assert_eq!(p(json!({"range": "current", "copies": 1e12})).map(|v| v.len()), Ok(MAX_COPIES as usize));
assert!(plan(&json!({"copies": 999}), 600, (0, 0)).is_err());
assert_eq!(p(json!({"copies": -5, "range": "current"})), Ok(vec![2]));
}
#[test]
fn print_pdf_writes_only_the_chosen_pages() {
let mut s = Session::new(crate::sample::sample_document());
let pages = s.export_layout().pages.len();
let (all, _) = s.print_pdf(&json!({})).unwrap();
let (one, plan) = s.print_pdf(&json!({"range": "current", "copies": 2})).unwrap();
assert!(all.starts_with(b"%PDF") && one.starts_with(b"%PDF"));
assert_eq!(plan.pages.len(), 2);
if pages > 2 {
assert!(one.len() < all.len());
}
assert!(s.print_pdf(&json!({"pages": "9999"})).is_err());
let r = s.run("file.printPdf", &json!({})).is_err();
assert!(r, "printPdf needs a path");
}
}

View File

@ -331,39 +331,15 @@ fn info_page(app: &mut WordApp, ui: &mut Ui) {
}
fn export_page(app: &mut WordApp, ui: &mut Ui) {
let is_print = app.ui.backstage_page == "print";
heading(ui, if is_print { "Print" } else { "Export" });
if is_print && app.services.print.is_some() {
ui.label(tl!("Send the document straight to the system print dialog, or save a copy in another format below."));
ui.add_space(12.0);
if ui.add(egui::Button::new(egui::RichText::new(tl!("Print")).font(medium(13.5))).min_size(vec2(320.0, 34.0))).clicked() {
let _ = app.run("ui.print", json!({}));
}
ui.add_space(12.0);
ui.separator();
ui.add_space(12.0);
ui.label(tl!("Or save a copy in another format:"));
} else {
ui.label(tl!("Save a copy in another format. Printing goes through a PDF you can print from any viewer."));
if app.ui.backstage_page == "print" {
heading(ui, "Print");
crate::print_page::show(app, ui);
return;
}
heading(ui, "Export");
ui.label(tl!("Save a copy in another format. Printing goes through a PDF you can print from any viewer."));
ui.add_space(12.0);
for (label, ext) in [
("PDF document (*.pdf)", "pdf"),
("Word document (*.docx)", "docx"),
("Word XML Document (*.xml)", "xml"),
("OpenDocument Text (*.odt)", "odt"),
("Rich Text Format (*.rtf)", "rtf"),
("Web page (*.html)", "html"),
("Markdown (*.md)", "md"),
("LaTeX (*.tex)", "tex"),
("Plain text (*.txt)", "txt"),
("Page image (*.png)", "png"),
] {
if ui.add(egui::Button::new(egui::RichText::new(tl!(label)).font(medium(13.5))).min_size(vec2(320.0, 34.0))).clicked() {
app.export_dialog(ext);
}
ui.add_space(4.0);
}
crate::print_page::export_formats(app, ui);
}
#[cfg(test)]

View File

@ -33,6 +33,8 @@ pub(crate) enum AfterPick {
SaveAs(AfterSave),
/// Save a copy there in this format (`pdf`, `docx`, `png`…).
Export { ext: String },
/// Write the PDF of these print settings there (File › Print › Save these pages as PDF).
PrintPdf { settings: Value },
/// Load the picked CSV/TSV/text file or spreadsheet as the mail-merge recipients (#240, #334).
Recipients,
/// Hand the picked path to the open dialog (`WordApp::dialog_pick`): Templates and Add-ins,
@ -151,6 +153,16 @@ impl WordApp {
}
r
}
AfterPick::PrintPdf { settings } => {
let Some(path) = picked else { return Ok(json!({"cancelled": true})) };
let mut params = settings;
params["path"] = json!(path);
let r = self.run("file.printPdf", params);
if r.is_ok() {
self.status(crate::i18n::fmt(tl!("Exported {path}"), &[("path", &path)]));
}
r
}
AfterPick::SaveAs(after) => {
let saved = picked.is_some_and(|path| {
self.run("file.save", json!({"path": path})).is_ok_and(|v| v.get("saved").and_then(Value::as_bool) == Some(true))
@ -191,6 +203,14 @@ impl WordApp {
let _ = self.ask_file(FileDialogRequest::Save { name }, AfterPick::Export { ext: ext.to_string() });
}
/// File › Print › Save these pages as PDF: ask where, then write the pages the print
/// settings choose.
pub(crate) fn print_pdf_dialog(&mut self) {
let name = format!("{}.pdf", self.title_stem());
let settings = self.ui.print.params();
let _ = self.ask_file(FileDialogRequest::Save { name }, AfterPick::PrintPdf { settings });
}
pub(crate) fn open_dialog(&mut self) {
if let Some(f) = &self.services.open_async {
f("document");

View File

@ -1696,6 +1696,21 @@
Continuous numbering Numeración continua
Apply Aplicar
Convert Notes Convertir notas
All pages Todas las páginas
Current page Página actual
Selected pages Páginas seleccionadas
Custom range Intervalo personalizado
All pages in the range Todas las páginas del intervalo
Odd pages only Solo páginas impares
Even pages only Solo páginas pares
Copies Copias
Collate copies Intercalar copias
Pages like 1-3, 5 Páginas como 1-3, 5
{count} of {total} pages will print Se imprimirán {count} de {total} páginas
Save these pages as PDF… Guardar estas páginas como PDF…
The printer, two-sided printing and pages per sheet are chosen in that dialog. La impresora, la impresión a doble cara y las páginas por hoja se eligen en ese cuadro de diálogo.
Previous Page Página anterior
Print to PDF Imprimir en PDF
Add Shape Agregar forma
Change Colors Cambiar colores
Change Layout Cambiar diseño

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -1657,6 +1657,21 @@
Continuous numbering Pidev nummerdus
Apply Rakenda
Convert Notes Teisenda märkused
All pages Kõik leheküljed
Current page Praegune lehekülg
Selected pages Valitud leheküljed
Custom range Kohandatud vahemik
All pages in the range Kõik vahemiku leheküljed
Odd pages only Ainult paaritud leheküljed
Even pages only Ainult paarisleheküljed
Copies Koopiad
Collate copies Järjesta koopiad
Pages like 1-3, 5 Leheküljed, nt 1-3, 5
{count} of {total} pages will print Prinditakse {count} lehekülge {total}-st
Save these pages as PDF… Salvesta need leheküljed PDF-ina…
The printer, two-sided printing and pages per sheet are chosen in that dialog. Printer, kahepoolne printimine ja lehekülgi lehe kohta valitakse selles dialoogis.
Previous Page Eelmine lehekülg
Print to PDF Prindi PDF-i
Add Shape Lisa kujund
Change Colors Muuda värve
Change Layout Muuda paigutust

Can't render this file because it has a wrong number of fields in line 12.

View File

@ -1680,6 +1680,21 @@
Continuous numbering 通し番号
Apply 適用
Convert Notes 注の変換
All pages すべてのページ
Current page 現在のページ
Selected pages 選択したページ
Custom range ページ範囲を指定
All pages in the range 範囲内のすべてのページ
Odd pages only 奇数ページのみ
Even pages only 偶数ページのみ
Copies 部数
Collate copies 部単位で印刷
Pages like 1-3, 5 ページ指定(例: 1-3, 5)
{count} of {total} pages will print {total} ページ中 {count} ページを印刷します
Save these pages as PDF… このページを PDF として保存…
The printer, two-sided printing and pages per sheet are chosen in that dialog. プリンター、両面印刷、1 枚あたりのページ数は、そのダイアログで選択します。
Previous Page 前のページ
Print to PDF PDF に印刷
Add Shape 図形の追加
Change Colors 色の変更
Change Layout レイアウトの変更

Can't render this file because it has a wrong number of fields in line 3.

View File

@ -1692,6 +1692,21 @@
Continuous numbering Numeração contínua
Apply Aplicar
Convert Notes Converter notas
All pages Todas as páginas
Current page Página atual
Selected pages Páginas selecionadas
Custom range Intervalo personalizado
All pages in the range Todas as páginas do intervalo
Odd pages only Somente páginas ímpares
Even pages only Somente páginas pares
Copies Cópias
Collate copies Agrupar cópias
Pages like 1-3, 5 Páginas como 1-3, 5
{count} of {total} pages will print {count} de {total} páginas serão impressas
Save these pages as PDF… Salvar estas páginas como PDF…
The printer, two-sided printing and pages per sheet are chosen in that dialog. A impressora, a impressão frente e verso e as páginas por folha são escolhidas nessa caixa de diálogo.
Previous Page Página anterior
Print to PDF Imprimir em PDF
Add Shape Adicionar forma
Change Colors Alterar cores
Change Layout Alterar layout

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -2125,6 +2125,21 @@ preview Title Пример
Continuous numbering Сквозная нумерация
Apply Применить
Convert Notes Преобразовать сноски
All pages Все страницы
Current page Текущая страница
Selected pages Выбранные страницы
Custom range Свой диапазон
All pages in the range Все страницы диапазона
Odd pages only Только нечётные страницы
Even pages only Только чётные страницы
Copies Копии
Collate copies Разобрать по копиям
Pages like 1-3, 5 Страницы, например 1-3, 5
{count} of {total} pages will print Будет напечатано страниц: {count} из {total}
Save these pages as PDF… Сохранить эти страницы как PDF…
The printer, two-sided printing and pages per sheet are chosen in that dialog. Принтер, двусторонняя печать и число страниц на листе выбираются в этом диалоге.
Previous Page Предыдущая страница
Print to PDF Печать в PDF
# Word XML Document (#548).
Word XML Document (*.xml) Документ Word XML (*.xml)

Can't render this file because it contains an unexpected character in line 7 and column 25.

View File

@ -1690,6 +1690,21 @@
Continuous numbering Neprekidna numeracija
Apply Primeni
Convert Notes Pretvori napomene
All pages Sve stranice
Current page Trenutna stranica
Selected pages Izabrane stranice
Custom range Prilagođeni opseg
All pages in the range Sve stranice u opsegu
Odd pages only Samo neparne stranice
Even pages only Samo parne stranice
Copies Kopije
Collate copies Složi kopije
Pages like 1-3, 5 Stranice poput 1-3, 5
{count} of {total} pages will print Štampaće se {count} od {total} stranica
Save these pages as PDF… Sačuvaj ove stranice kao PDF…
The printer, two-sided printing and pages per sheet are chosen in that dialog. Štampač, obostrana štampa i broj stranica po listu biraju se u tom dijalogu.
Previous Page Prethodna stranica
Print to PDF Štampaj u PDF
Add Shape Dodaj oblik
Change Colors Promeni boje
Change Layout Promeni raspored

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -1694,6 +1694,21 @@
Continuous numbering Непрекидна нумерација
Apply Примени
Convert Notes Претвори напомене
All pages Све странице
Current page Тренутна страница
Selected pages Изабране странице
Custom range Прилагођени опсег
All pages in the range Све странице у опсегу
Odd pages only Само непарне странице
Even pages only Само парне странице
Copies Копије
Collate copies Сложи копије
Pages like 1-3, 5 Странице попут 1-3, 5
{count} of {total} pages will print Штампаће се {count} од {total} страница
Save these pages as PDF… Сачувај ове странице као PDF…
The printer, two-sided printing and pages per sheet are chosen in that dialog. Штампач, обострана штампа и број страница по листу бирају се у том дијалогу.
Previous Page Претходна страница
Print to PDF Штампај у PDF
Add Shape Додај облик
Change Colors Промени боје
Change Layout Промени распоред

Can't render this file because it contains an unexpected character in line 11 and column 25.

View File

@ -1747,6 +1747,21 @@
Continuous numbering Sürekli numaralandırma
Apply Uygula
Convert Notes Notları Dönüştür
All pages Tüm sayfalar
Current page Geçerli sayfa
Selected pages Seçili sayfalar
Custom range Özel aralık
All pages in the range Aralıktaki tüm sayfalar
Odd pages only Yalnızca tek sayfalar
Even pages only Yalnızca çift sayfalar
Copies Kopya
Collate copies Kopyaları harmanla
Pages like 1-3, 5 Sayfalar, örn. 1-3, 5
{count} of {total} pages will print {total} sayfadan {count} sayfa yazdırılacak
Save these pages as PDF… Bu sayfaları PDF olarak kaydet…
The printer, two-sided printing and pages per sheet are chosen in that dialog. Yazıcı, çift taraflı yazdırma ve yaprak başına sayfa sayısı o iletişim kutusunda seçilir.
Previous Page Önceki sayfa
Print to PDF PDF'ye yazdır
# Word XML Document (#548).
Word XML Document (*.xml) Word XML Belgesi (*.xml)

Can't render this file because it has a wrong number of fields in line 18.

View File

@ -1692,6 +1692,21 @@
Continuous numbering Наскрізна нумерація
Apply Застосувати
Convert Notes Перетворити примітки
All pages Усі сторінки
Current page Поточна сторінка
Selected pages Вибрані сторінки
Custom range Власний діапазон
All pages in the range Усі сторінки діапазону
Odd pages only Лише непарні сторінки
Even pages only Лише парні сторінки
Copies Копії
Collate copies Розсортувати копії
Pages like 1-3, 5 Сторінки, наприклад 1-3, 5
{count} of {total} pages will print Буде надруковано {count} зі сторінок: {total}
Save these pages as PDF… Зберегти ці сторінки як PDF…
The printer, two-sided printing and pages per sheet are chosen in that dialog. Принтер, двобічний друк і кількість сторінок на аркуші вибирають у цьому діалозі.
Previous Page Попередня сторінка
Print to PDF Друк у PDF
Add Shape Додати фігуру
Change Colors Змінити кольори
Change Layout Змінити макет

Can't render this file because it contains an unexpected character in line 6 and column 31.

View File

@ -1692,6 +1692,21 @@
Continuous numbering 连续编号
Apply 应用
Convert Notes 转换注释
All pages 所有页面
Current page 当前页
Selected pages 所选页面
Custom range 自定义范围
All pages in the range 范围内的所有页面
Odd pages only 仅奇数页
Even pages only 仅偶数页
Copies 份数
Collate copies 逐份打印
Pages like 1-3, 5 页码,如 1-3, 5
{count} of {total} pages will print 将打印 {total} 页中的 {count} 页
Save these pages as PDF… 将这些页面另存为 PDF…
The printer, two-sided printing and pages per sheet are chosen in that dialog. 打印机、双面打印和每张页数在该对话框中选择。
Previous Page 上一页
Print to PDF 打印到 PDF
Add Shape 添加形状
Change Colors 更改颜色
Change Layout 更改布局

Can't render this file because it contains an unexpected character in line 7 and column 25.

View File

@ -1681,6 +1681,21 @@
Continuous numbering 連續編號
Apply 套用
Convert Notes 轉換附註
All pages 所有頁面
Current page 目前頁面
Selected pages 所選頁面
Custom range 自訂範圍
All pages in the range 範圍內的所有頁面
Odd pages only 僅奇數頁
Even pages only 僅偶數頁
Copies 份數
Collate copies 逐份列印
Pages like 1-3, 5 頁碼,如 1-3, 5
{count} of {total} pages will print 將列印 {total} 頁中的 {count} 頁
Save these pages as PDF… 將這些頁面另存為 PDF…
The printer, two-sided printing and pages per sheet are chosen in that dialog. 印表機、雙面列印和每張頁數在該對話方塊中選擇。
Previous Page 上一頁
Print to PDF 列印為 PDF
Add Shape 新增圖案
Change Colors 變更色彩
Change Layout 變更版面配置

Can't render this file because it has a wrong number of fields in line 4.

View File

@ -50,6 +50,7 @@ pub mod options;
pub mod panes;
pub mod paste_picture;
pub mod previews;
pub mod print_page;
pub mod read_aloud;
pub mod ribbon;
pub mod scroll;
@ -108,6 +109,9 @@ pub struct UiState {
pub tab: String,
pub backstage: bool,
pub backstage_page: String,
/// File › Print settings (#641); not saved.
#[serde(skip)]
pub print: print_page::PrintSettings,
pub ribbon_collapsed: bool,
pub recent: Vec<String>,
/// Symbols inserted from the Symbol dialog, most recent first (#321).
@ -185,6 +189,7 @@ impl Default for UiState {
tab: "Home".into(),
backstage: false,
backstage_page: "home".into(),
print: print_page::PrintSettings::default(),
ribbon_collapsed: false,
recent: Vec::new(),
recent_symbols: Vec::new(),
@ -815,7 +820,7 @@ impl WordApp {
// Send the document to the system print flow (web, desktop). `file.print` opens the Print
// page; this is the button on it, and the one programmatic call that opens system UI
// here, like `ui.openFileDialog` — only when the host can print.
"ui.print" => return Some(self.print_to_system()),
"ui.print" => return Some(self.print_to_system(p)),
"ui.discord" => {
self.canvas.open_url = Some("https://discord.gg/artcraft".into());
json!({})
@ -834,8 +839,10 @@ impl WordApp {
}
/// `ui.print`: export the document to PDF in memory and hand it to the host's print hook.
/// `p` holds the print settings (`range`, `pages`, `parity`, `copies`, `collate`; see
/// `file.printPdf`); without any, every page prints once.
/// An error when the host can't print (File › Print then only saves a PDF).
fn print_to_system(&mut self) -> Result<Value, String> {
fn print_to_system(&mut self, p: &Value) -> Result<Value, String> {
if self.services.print.is_none() {
return Err("printing to the system print dialog isn't available here; export a PDF instead".into());
}
@ -845,13 +852,12 @@ impl WordApp {
{
log::warn!("updating fields before printing: {e}");
}
let without_colour = (!self.ui.print_backgrounds && self.session.doc.settings.page_color.is_some()).then(|| {
let mut d = self.session.doc.clone();
d.settings.page_color = None;
d
});
let result = wordcraft_engine::io::save_bytes("document.pdf", without_colour.as_ref().unwrap_or(&self.session.doc))
.and_then(|bytes| self.services.print.as_ref().map_or(Ok(()), |print| print(&bytes)).map(|()| bytes.len()));
let colour = if self.ui.print_backgrounds { None } else { self.session.doc.settings.page_color.take() };
let pdf = self.session.print_pdf(p).map_err(|e| e.to_string());
if colour.is_some() {
self.session.doc.settings.page_color = colour;
}
let result = pdf.and_then(|(bytes, _)| self.services.print.as_ref().map_or(Ok(()), |print| print(&bytes)).map(|()| bytes.len()));
match result {
Ok(len) => {
self.status(tl!("Opening print dialog…"));
@ -1924,6 +1930,23 @@ mod tests {
assert!(a.status_msg.as_ref().is_some_and(|(m, _)| m.contains("blocked by the browser")), "the status bar says why");
}
/// The print settings reach the hook: bad ones print nothing, `copies` repeats the pages.
#[test]
fn ui_print_honours_the_print_settings() {
let mut a = typed();
let sizes = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let seen = sizes.clone();
a.services.print = Some(Box::new(move |bytes| {
seen.borrow_mut().push(bytes.len());
Ok(())
}));
assert!(a.run("ui.print", json!({"pages": "7-9"})).is_err(), "page 7 doesn't exist");
assert!(a.run("ui.print", json!({"range": "nope"})).is_err());
assert!(sizes.borrow().is_empty(), "nothing was sent");
a.run("ui.print", json!({"range": "current", "copies": 3})).unwrap();
assert_eq!(sizes.borrow().len(), 1);
}
/// The Print page shows the Print button only when the host can print, and the button
/// prints through `ui.print`.
#[test]

View File

@ -0,0 +1,238 @@
//! File › Print (#641): the print settings and a preview of the pages.
//!
//! WordCraft prints through a PDF that the host opens in the system's print flow, so the settings
//! here are the ones that shape that PDF: which pages, odd/even, copies and collation. They go to
//! `ui.print` / `file.printPdf` as params (see `wordcraft_engine::print`). The printer, two-sided
//! printing and pages per sheet are chosen in the system print dialog that opens next.
use std::sync::Arc;
use egui::{Rect, Sense, Stroke, TextureHandle, Ui, pos2, vec2};
use serde_json::{Value, json};
use wordcraft_layout::DocLayout;
use crate::theme::{Tokens, medium, regular};
use crate::{WordApp, icons};
/// The Print page's settings. Not saved with the document or the UI state.
#[derive(Clone, Debug, PartialEq)]
pub struct PrintSettings {
/// `all`, `current`, `selection` or `custom`.
pub range: String,
/// The custom range text, like `1-3, 5`.
pub pages: String,
/// `all`, `odd` or `even`.
pub parity: String,
pub copies: u32,
pub collate: bool,
/// The page shown in the preview (0-based).
pub preview: usize,
}
impl Default for PrintSettings {
fn default() -> Self {
PrintSettings { range: "all".into(), pages: String::new(), parity: "all".into(), copies: 1, collate: true, preview: 0 }
}
}
impl PrintSettings {
/// The params of `ui.print` and `file.printPdf` for these settings.
pub fn params(&self) -> Value {
let mut v = json!({"range": self.range, "parity": self.parity, "copies": self.copies, "collate": self.collate});
if self.range == "custom" {
v["pages"] = json!(self.pages);
}
v
}
}
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")];
/// A choice list over `(id, label)` pairs; true when the choice changed.
fn choice(ui: &mut Ui, salt: &str, current: &mut String, items: &[(&str, &str)]) -> bool {
let shown = items.iter().find(|(id, _)| *id == current.as_str()).map_or("", |(_, l)| *l);
let mut changed = false;
egui::ComboBox::from_id_salt(salt).width(260.0).selected_text(tl!(shown)).show_ui(ui, |ui| {
for (id, label) in items {
if ui.selectable_label(current == id, tl!(label)).clicked() && current != id {
*current = (*id).to_string();
changed = true;
}
}
});
changed
}
/// The output layout, kept while the document is unchanged (laying out every frame would be slow).
fn layout_of(app: &WordApp, ctx: &egui::Context) -> Arc<DocLayout> {
let id = egui::Id::new("print_page_layout");
let rev = app.session.rev();
if let Some((r, l)) = ctx.data(|d| d.get_temp::<(u64, Arc<DocLayout>)>(id))
&& r == rev
{
return l;
}
let l = app.session.export_layout();
ctx.data_mut(|d| d.insert_temp(id, (rev, l.clone())));
l
}
/// The texture of one page, re-rendered only when the document, page or size changes.
fn page_texture(app: &WordApp, ctx: &egui::Context, lay: &DocLayout, page: usize, width: f32) -> Option<TextureHandle> {
let id = egui::Id::new("print_page_texture");
let key = (app.session.rev(), page, width as u32, (ctx.pixels_per_point() * 100.0) as u32);
if let Some((k, t)) = ctx.data(|d| d.get_temp::<((u64, usize, u32, u32), TextureHandle)>(id))
&& k == key
{
return Some(t);
}
let pg = lay.pages.get(page)?;
let scale = (width / pg.w.max(1.0) * ctx.pixels_per_point()).clamp(0.05, 4.0);
let img = wordcraft_render::render_page(&app.session.doc, pg, scale, &Default::default());
let color = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.to_straight());
let tex = ctx.load_texture("print_page_preview", color, egui::TextureOptions::LINEAR);
ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone())));
Some(tex)
}
/// The Print page: settings on the left, the preview on the right.
pub fn show(app: &mut WordApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let ctx = ui.ctx().clone();
let lay = layout_of(app, &ctx);
let total = lay.pages.len();
let current = app.session.current_pages(&lay);
app.ui.print.preview = app.ui.print.preview.min(total.saturating_sub(1));
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(380.0);
ui.spacing_mut().item_spacing.y = 8.0;
let hook = app.services.print.is_some();
if hook {
ui.label(tl!("Send the document straight to the system print dialog, or save a copy in another format below."));
ui.label(
egui::RichText::new(tl!("The printer, two-sided printing and pages per sheet are chosen in that dialog.")).color(t.text_dim),
);
} else {
ui.label(tl!("Save a copy in another format. Printing goes through a PDF you can print from any viewer."));
}
ui.add_space(4.0);
if hook && ui.add(egui::Button::new(egui::RichText::new(tl!("Print")).font(medium(13.5))).min_size(vec2(320.0, 34.0))).clicked() {
let params = app.ui.print.params();
let _ = app.run("ui.print", params);
}
if ui
.add(egui::Button::new(egui::RichText::new(tl!("Save these pages as PDF…")).font(medium(13.5))).min_size(vec2(320.0, 34.0)))
.clicked()
{
app.print_pdf_dialog();
}
ui.add_space(6.0);
ui.label(egui::RichText::new(tl!("Copies")).font(medium(13.0)));
ui.horizontal(|ui| {
let mut n = app.ui.print.copies;
ui.add(egui::DragValue::new(&mut n).range(1..=wordcraft_engine::print::MAX_COPIES).speed(0.2));
app.ui.print.copies = n.clamp(1, wordcraft_engine::print::MAX_COPIES);
if app.ui.print.copies > 1 {
ui.checkbox(&mut app.ui.print.collate, tl!("Collate copies"));
}
});
ui.label(egui::RichText::new(tl!("Pages")).font(medium(13.0)));
if choice(ui, "print_range", &mut app.ui.print.range, &RANGES) {
// Show the first page that will print.
app.ui.print.preview = if app.ui.print.range == "all" { 0 } else { current.0.min(total.saturating_sub(1)) };
}
if app.ui.print.range == "custom" {
ui.add(
egui::TextEdit::singleline(&mut app.ui.print.pages)
.hint_text(tl!("Pages like 1-3, 5"))
.desired_width(260.0)
.char_limit(wordcraft_engine::print::MAX_SPEC_CHARS),
);
}
choice(ui, "print_parity", &mut app.ui.print.parity, &PARITIES);
match wordcraft_engine::print::plan(&app.ui.print.params(), total, current) {
Ok(plan) => {
let msg = crate::i18n::fmt(
tl!("{count} of {total} pages will print"),
&[("count", &plan.chosen.to_string()), ("total", &total.to_string())],
);
ui.label(egui::RichText::new(msg).color(t.text_dim));
}
Err(e) => {
ui.label(egui::RichText::new(e.to_string()).color(t.red));
}
}
ui.add_space(8.0);
ui.separator();
ui.add_space(4.0);
if hook {
ui.label(tl!("Or save a copy in another format:"));
}
export_formats(app, ui);
});
ui.add_space(24.0);
ui.vertical(|ui| preview(app, ui, &lay, total));
});
}
/// The preview: one page with Previous/Next.
fn preview(app: &mut WordApp, ui: &mut Ui, lay: &DocLayout, total: usize) {
let t = Tokens::get(ui.ctx());
let ctx = ui.ctx().clone();
let width = 340.0;
let page = app.ui.print.preview;
let aspect = lay.pages.get(page).map_or(11.0 / 8.5, |p| p.h.max(1.0) / p.w.max(1.0)).clamp(0.2, 5.0);
let (r, _) = ui.allocate_exact_size(vec2(width, width * aspect), Sense::hover());
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, width) {
ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), egui::Color32::WHITE);
}
ui.add_space(6.0);
ui.horizontal(|ui| {
let nav = |ui: &mut Ui, icon: &str, tip: &str, enabled: bool| {
let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::click());
let col = if enabled { t.icon } else { t.text_disabled };
if enabled && resp.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
icons::paint(ui.painter(), r.shrink(4.0), icon, col, col);
enabled && resp.on_hover_text(tip).clicked()
};
if nav(ui, "chevronLeft", tl!("Previous Page"), page > 0) {
app.ui.print.preview = page - 1;
}
let label = crate::i18n::fmt(tl!("Page {page} of {pages}"), &[("page", &(page + 1).to_string()), ("pages", &total.to_string())]);
ui.label(egui::RichText::new(label).font(regular(13.0)));
if nav(ui, "chevronRight", tl!("Next Page"), page + 1 < total) {
app.ui.print.preview = page + 1;
}
});
}
/// The "save a copy" formats (File › Export, and below the settings on the Print page).
pub fn export_formats(app: &mut WordApp, ui: &mut Ui) {
for (label, ext) in [
("PDF document (*.pdf)", "pdf"),
("Word document (*.docx)", "docx"),
("Word XML Document (*.xml)", "xml"),
("OpenDocument Text (*.odt)", "odt"),
("Rich Text Format (*.rtf)", "rtf"),
("Web page (*.html)", "html"),
("Markdown (*.md)", "md"),
("LaTeX (*.tex)", "tex"),
("Plain text (*.txt)", "txt"),
("Page image (*.png)", "png"),
] {
if ui.add(egui::Button::new(egui::RichText::new(tl!(label)).font(medium(13.5))).min_size(vec2(320.0, 34.0))).clicked() {
app.export_dialog(ext);
}
ui.add_space(4.0);
}
}

View File

@ -16,7 +16,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes.
| 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170) (lag on GPUs without a DirectX 12 driver fixed, #316), freezes (#77, #59 Thai fonts, #31 font selector), no writes without admin on Windows 11 (#218), broken shape tool (#142), images can't be moved (#82) | B | 62 open issues, ~15 of them crashes, hangs or data-path failures | Anyone hit loses work or trust; the never-crash standard covers panics, not hangs or GPU driver faults | 20–30 | [target-app-parity.md](target-app-parity.md) (Stability) |
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options ignored; no page-by-page comparison harness | B | [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-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; still missing: drag-and-drop moves (no text drag yet) and move options in Track Changes Options. 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 | B | 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 | **No native printing** on desktop: File › Print opens the PDF in the system viewer, which prints (#286); no print dialog of our own (printer, copies, ranges); 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 | **No native printing** on desktop: File › Print opens the PDF in the system viewer, which prints (#286); File › Print has a preview, page range, odd/even, copies and collate (#641); printer choice, duplex and pages per sheet are still the system's; 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**: 54 modal dialogs vs Word's ~100 | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) |
| 8 | **Proofing is English only**; no language per run, no dictionaries for other languages | | `crates/proof` has one dictionary; Word ships 120 proofing tools; issues #25, #40, #100 | Every non-English writer | 25–45 | [localization-parity.md](localization-parity.md) |
| 9 | **Password-protected files** can't be opened or saved | | #55; `docbin` detects and refuses RC4/XOR | Corporate and legal users | 6–10 | [file-format-parity.md](file-format-parity.md) |

View File

@ -21,7 +21,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
| Modal dialogs | **54** (`Dialog` enum: Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 |
| Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation, Styles, Comments, Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 |
| Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339); right-click selects a picture, shape or text box and offers Wrap Text (styles, Move with Text / Fix Position on Page, #513); tables partial | rich per context | 60% | 3–5 |
| Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485) | | 68% | 4–6 |
| Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641) | | 68% | 4–6 |
| Rulers: indents, tabs, margins, table columns | indents draggable (one undo, #62); tabs; table column drag pending (#49) | ✅ | 70% | 2–3 |
| On-canvas objects: select, drag, resize handles, nudge | ✅ tested (#228) | ✅ | 75% | — |
| Rotation handle, crop handles on canvas, alignment guides, smart guides | rotation handle (Shift: 15° steps), turned frames resize along their axes (#332); crop handles and guides missing | ✅ | 25% | 3–5 |