From 2aea7dcfeb64284882c23bd2d82e846e6e6f388f Mon Sep 17 00:00:00 2001 From: Raj Patel Date: Mon, 12 Oct 2026 00:31:36 -0600 Subject: [PATCH] M10: print from the web build through the browser's print dialog (gap 4) (#1243) * Gates: fix two clippy failures on main - ocr_ui: "Also recognize pages that already have text" used egui's own checkbox, which clippy.toml disallows (#1184 landed beside #1173); use widgets::checkbox like every other box. - camera_edges: Rust 1.97's manual_range_contains; the rewrite keeps the old answer for a NaN angle (false) with an is_finite check. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019ojKsSCHWk2AuyDTFMHCd7 * M10: print from the web build through the browser's print dialog (gap 4) The web build had no printer: spool::submit refuses on wasm, so Print could only download the print-ready PDF. Now its Print dialog offers "Browser's print dialog" (the default there), which reaches every printer the operating system knows. - web_print: PdfCraft draws the print-ready PDF's sheets itself (engine Exporter), one sheet a frame since wasm has no threads: 300 dpi up to 100 sheets, 200 up to 300, 150 up to 1,000, at most 64 MP a sheet and 1 GiB of PNGs a job. The PNGs become blob URLs and print from a hidden srcdoc iframe, one sheet per page, margin 0. - Chromium prints every page at the default @page size, so a sheet of the paper's size turned sideways is rotated 90 degrees CCW onto it (as drivers do); other sizes get named @page rules. - Copies, two-sided and colour are greyed out (the browser's dialog sets them). If print() is refused (e.g. a sandboxed frame), the print-ready PDF downloads instead, with the reason. - pdfcraft-print stays free of web APIs; six new strings in all 20 catalogs (ATTRIBUTION hashes refreshed); docs and parity updated, gap 4 closed. Verified in headless Chromium against a trunk build: three sheets at 300 dpi, the landscape sheet turned, no blank trailing page, and the download fallback. Firefox and Safari not yet tried. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019ojKsSCHWk2AuyDTFMHCd7 --------- Co-authored-by: Claude Co-authored-by: Brandon Thomas --- ATTRIBUTION.toml | 40 +-- README.md | 2 +- ROADMAP.md | 5 +- crates/print/README.md | 6 +- crates/ui-egui/Cargo.toml | 2 +- crates/ui-egui/src/i18n/ar.tsv | 6 + crates/ui-egui/src/i18n/bg.tsv | 6 + crates/ui-egui/src/i18n/cs.tsv | 6 + crates/ui-egui/src/i18n/de.tsv | 6 + crates/ui-egui/src/i18n/el.tsv | 6 + crates/ui-egui/src/i18n/es.tsv | 6 + crates/ui-egui/src/i18n/fr.tsv | 6 + crates/ui-egui/src/i18n/hu.tsv | 6 + crates/ui-egui/src/i18n/it.tsv | 6 + crates/ui-egui/src/i18n/ja.tsv | 6 + crates/ui-egui/src/i18n/kk.tsv | 6 + crates/ui-egui/src/i18n/pl.tsv | 6 + crates/ui-egui/src/i18n/pt-br.tsv | 6 + crates/ui-egui/src/i18n/ru.tsv | 6 + crates/ui-egui/src/i18n/sl.tsv | 6 + crates/ui-egui/src/i18n/te.tsv | 6 + crates/ui-egui/src/i18n/tr.tsv | 6 + crates/ui-egui/src/i18n/uk.tsv | 6 + crates/ui-egui/src/i18n/zh-hans.tsv | 6 + crates/ui-egui/src/i18n/zh-hant.tsv | 6 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/print_ui.rs | 162 ++++++++-- crates/ui-egui/src/web_print.rs | 453 ++++++++++++++++++++++++++++ docs/gaps.md | 17 +- docs/hardware-parity.md | 7 +- docs/roadmap.md | 9 +- docs/target-app-parity.md | 9 +- parity/acrobat-features.toml | 8 +- 33 files changed, 769 insertions(+), 72 deletions(-) create mode 100644 crates/ui-egui/src/web_print.rs diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 1c354fe64..06278bd7b 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2200c7b374baffb569253eadf597e9ca0bff39324830cde30011617a5f1ee192" +sha256 = "0de16c8768d6407bd49d29991d7a079466b20d6034b4e5dd9e599f616888ec41" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1218460f576264b584b0e8d58b861ccf532a1029b5ec32a1e60a941cdffa3505" +sha256 = "428b841cb1d15d6a2735f7509d2170b1b0a11a87fa0fec04760c1cf895bbb684" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "46a79f58780806f0f581ef8b6100269a9022214f59f288e59a9a2822997d7aeb" +sha256 = "29b50165e96db49599478d46ae9a07af496189e715e4ec063e3768aa919d8aab" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "3aee40e8fc1f3961cfdb97c01d6fb60aa567136d8d08b546ffb7c78cf9433811" +sha256 = "941a40cc109e57d33040d9a81eceed8fe50afbfe1be2594c214684b1a89c463a" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "b4d4b329ca741bc097bb52185cc88a2f381a94026fd968123b47c54d943dedb3" +sha256 = "ef66c8a4e9e992d31ca69d95ee8ff11e1298a4a1045d08ab7b24f250ff7af79e" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "64ff3e12c9065505bc47c5d847ed8aaa436de3e1f8442a9d43ccc14b516a7b19" +sha256 = "c7ea41b09d24f87f1c8ceb5bcfadcd8b2cae348dc3927770c1227180b59fdb13" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "954cb27d741c49741e372cf6a18c22b28214c7fffd1088119b8f6388b27a869b" +sha256 = "56d736cefc2189e8dadf11418a7701a746bbf237c58a628e3c1f0ecb64cf5150" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "dc971a2990664c916e65686dfcd0c1e226855573556769bac6cf4ade8845e349" +sha256 = "9777ed471a3b641a278cf3d1c41b6011bcd1c353fe3ef8e84d1eab350e0b5531" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "de419eeb6f631ad7ed4b0c409c31ef7a13a6c7bf1c009dbc61eb9a9a1d2663da" +sha256 = "629b769efd3535f31e73bb3a18e9cef7efc5880e4fabb928cad789474dcbf283" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "963c2f7ed3a84f4c6a66897d21cded494026a6088304a1cb39c8cef032b02ca9" +sha256 = "bc0399c21f0420dd50aa38a596b8d165089874ffabee56de587803ff45e4b79d" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1d3a3cc44812643f8612b3ae921025daad0f52f8c237f2456f4162db955e196b" +sha256 = "f0e696bd3e046f82f5c2499f9c41e1c023293de3c65d6d71a3324bcd2ede4a78" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c619710b677655a8604c646d2028c2ccc95e756bbb03849727f67205f9fe6992" +sha256 = "a2884a44cb42ec85d363417e999b17e2a6f136cacb2112c5ae7caa5a79db3a13" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1fe0610a52cb2c9afdeef8b81e3d28aea9b74764b3c147dda6088ed5652de8b7" +sha256 = "8300d27b697cf724a61425c470675cb47f3aa6f57be0b2e072e1516a52ee9088" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "61600a2c313484fe773515169eaa114e5057eef8194aa44f862372f727c5d20b" +sha256 = "d4385634667253f26ccc71924786b826bced04fc04448748e3d9d96254c67a08" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "99d683a50491edcbb8cc941291d8cb9c223cd9f9e4bbc8ae756bbba2ab51fccf" +sha256 = "571bc6cb212380db31b01f6c7efe9355cba085e4b3d834d7d835565f1eb62550" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "72555257a12e4807df23c6719d22b1a2395c90553ed7fbf17c2c24fbbe108dfc" +sha256 = "914d25f465fb951025f2ef8351626992993cbf70f4e76b9cf55ac9b164b882ae" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "4b10d18ba3730cf651b223bd1bd106418729ab9e058aafc92a4ac4808453f050" +sha256 = "ae5ec9ce178c91f843773657a609a32be8ed30800addc17e383415c182417215" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "64cf360b242de2043f69b09ff53c61672515dd23f18330323a72a9a57fefbb9e" +sha256 = "8974a9566ffa987a76ed3be123ea9e7899cea83ee79ad42c2d289982329eadff" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ed3e33cee1b8a2501b8c3fc89465462049495ff165a49d74a7c6a7b4b90d6d96" +sha256 = "20b7d9813a3be3da0b111f0b7ff93a91f0ded704ccacd431a7e6de3a1a0bc9ed" [[asset]] path = "assets/icons/accessibility.svg" @@ -3328,4 +3328,4 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c993902f3fd77e1d2db78da1ed4c66a611199127c482119d80e5e6afad50a734" +sha256 = "3e9c1bdfd0b2c47da16922669a195e6a06018d5b6701b2cc19c18850d03cfd87" diff --git a/README.md b/README.md index 3947c29ce..fce87cf3d 100644 --- a/README.md +++ b/README.md @@ -422,7 +422,7 @@ PdfCraft is young and moving fast. The aim is a workbench where you can view, or - **Thin or missing:** reliable editing of existing text (especially CJK and right-to-left), OCR beyond Latin script, Office import/export, signature tokens and online long-term validation, Preflight and PDF/X/UA, printing from the web build, physical printer fidelity, and full XFA forms. - **Hardening:** fuzzing still turns up crashes and hangs on hostile files; each one is fixed with a regression test. Quality has not yet been compared with Acrobat side by side. -**Next, in order:** Office-to-PDF and a Word export that keeps layout, the reported bugs, web printing and the Chinese interface font, a fidelity harness against Acrobat and the renderer, editing existing content, then the Pro workflows (OCR, Office, Preflight, signatures, accessibility tagging) and 1.0 polish. +**Next, in order:** Office-to-PDF and a Word export that keeps layout, the reported bugs and the Chinese interface font, a fidelity harness against Acrobat and the renderer, editing existing content, then the Pro workflows (OCR, Office, Preflight, signatures, accessibility tagging) and 1.0 polish. The summary is in **[ROADMAP.md](ROADMAP.md)**; the full assessment by dimension and area is in [docs/target-app-parity.md](docs/target-app-parity.md), the ranked gaps in [docs/gaps.md](docs/gaps.md), and the milestones in [docs/roadmap.md](docs/roadmap.md). diff --git a/ROADMAP.md b/ROADMAP.md index fe74d300b..f458ecb34 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -46,7 +46,7 @@ and add a line to the [progress log](#progress-log); when a gap closes, remove i of 0.4–0.5, switching reports such as #179). Two are only partial: editing (no CJK/RTL) and convert and share (no in-app Office→PDF; Word export loses layout, #773; password-protected files open in Acrobat since #836, #774). Those, unmeasured quality against Acrobat, the borrowed `hayro` renderer, -no printing on the web and 245 open issues keep it far from beta. +and 245 open issues keep it far from beta. ### Readiness by audience @@ -126,7 +126,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | Rank | Milestone | Estimate (h) | |---|---|---| | 0 | Convert and share: Create PDF from Office, Word export that keeps layout (#773) | 32–55 | -| 1 | Backlog and platform: crash and wrong-result reports, web printing, Chinese UI face (#826) | 65–120 | +| 1 | Backlog and platform: crash and wrong-result reports, Chinese UI face (#826) | 50–90 | | 2 | Fidelity harness against Acrobat and the renderer decision (M2; owner decision on #841) | 110–190 | | 3 | Editing existing content (M7) | 100–180 | | 4 | OCR languages and Office export/import (M10) | 95–175 | @@ -164,6 +164,7 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-11 (M5.7, scanned signatures, #1110):** Fill & Sign ▸ Add signature has a Scan tab: a phone photo of a signature on paper is framed automatically (all words; table, sheet edge and dust ignored; a sideways signature turned upright), the frame can be moved, resized or redrawn, the photo turned, and an ink-sensitivity slider picks up faint ink. A morphological paper estimate removes shadows and uneven light, and the trimmed ink is saved and reused like an image signature. Photos and images can also be dropped on the Image and Scan tabs. Photos keep the image-signature limits (#1124: a 4 MiB file, up to 32 megapixels decoded, scaled down to 4 megapixels), so a scan never processes more than 4 megapixels. `fill_sign_add` takes `scan` and `sensitivity`. Synthetic engine, UI and automation tests; checked by hand on real phone photos. Milestone and effort estimates unchanged. - **2026-10-12 (M10.5, hosted CI):** Integrated current main, including its date-table formatting and static-storage fixes. The Arabic synthetic drawing regression now checks the original serialized-output golden with and without the optional font face: the face-dependent FontName and FontFamily are checked separately and normalized only in the fixture. The hosted FreeBSD fingerprint is reproduced by those two metadata fields alone. Drawing bytes and all other fields retain the original golden; no printing behavior or readiness estimate changes. +- **2026-10-11 (M10, gap 4):** The web build prints. Its Print dialog offers "Browser's print dialog" (the default there), which reaches every printer the operating system knows. PdfCraft draws the print-ready PDF's sheets itself with its own renderer, a sheet a frame because the web build has no threads: 300 dpi up to 100 sheets, 200 up to 300, 150 up to 1,000, at most 64 megapixels a sheet and 1 GiB of pictures a job. It then prints them from a hidden iframe, one sheet per printed page with no margin. A landscape sheet among portrait ones is turned onto the paper, as a driver does, because Chromium prints every page at the default `@page` size. Copies, two-sided and colour are set in the browser's dialog, so the dialog greys them out. If the browser refuses to print (a sandboxed frame, for example), the print-ready PDF downloads instead. Verified in headless Chromium: the right sheets at 300 dpi, the landscape sheet turned, no blank trailing page, and the download fallback. Not yet tested in Firefox or Safari. Gap 4 closed; rank 1 is 65–120 → 50–90 h; other estimates unchanged. - **2026-10-11 (M10.5):** Extracted printer discovery, options and PDF submission into craft-libs/craft-print, shared with DesignCraft. Windows preserves composed sheet size and orientation without fitting into default margins; duplex binding edges are corrected. PdfCraft retains permission checks and imposition. Shared transport and local composition, engine, automation and UI coverage validate the boundary; native Windows CI passes, including paused-queue submission and cleanup; physical printer fidelity remains a hardware check. Rust 1.95 is required by the shared crate. No readiness percentage change. - **2026-10-11 (M10, #1028):** Recognize Text can read pages that already have text: "Also recognize pages that already have text" in the dialog (remembered with the other choices, and used by Recognize text in multiple files) and `skip_text_pages: false` on `ocr_recognize` and, newly, `ocr_recognize_files`. A picture on such a page becomes searchable, and a recognised word at least half covered by the page's own text (an earlier OCR layer included) is left out, so nothing is found twice; `ocr_recognize` reports how many were left out as `already_on_page`. Before, the option existed only for agents and added the page's text a second time. The default still skips pages with text. Synthetic tests (a picture of a sentence plus real text) cover the engine, the tool and the dialog, and fail with the filter turned off. Still missing: reading only the picture areas (the whole page is read, as for a scan). Separately, `recognize_text_makes_a_scanned_page_searchable` fails on main wherever the models are installed (the model reads "quick" as "auick"). Estimates unchanged. - **2026-10-11 (M2, page-label prefixes):** The page-label readers bound the total size of decoded prefixes. Each range decodes its own prefix, and ranges may share one prefix object, so a small file could ask for a copy per range: within the 200,000-entry tree limit, about 200 MiB of copies of one 1,024-byte prefix. Inspection and Organize now refuse a tree whose decoded prefixes pass 4 MiB in total (`MAX_PREFIX_TOTAL_BYTES`), before any label is made, as the other tree limits do; relabelling such a tree changes nothing. Synthetic tests share one 1,000-byte prefix across 5,000 ranges and keep exactly 4 MiB of prefixes readable. Overall estimate unchanged. diff --git a/crates/print/README.md b/crates/print/README.md index a7514023b..18bf58ee9 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -47,7 +47,11 @@ The desktop UI discovers printers and submits jobs on workers, so neither operat the dialog; the Windows backend starts without a console window. Physical printer output still needs hardware validation. -Not yet: the web spooler, print as image, poster labels, PostScript output, colour conversion for +In a browser there is no spooler: the app (`pdfcraft-ui-egui`'s `web_print`) draws the print-ready +PDF's sheets with PdfCraft's renderer and prints them through the browser's print dialog, which +sets copies, two-sided printing and colour. This crate stays free of web APIs. + +Not yet: print as image, poster labels, PostScript output, colour conversion for grayscale. ## Architectural sheets and scale diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 3e3b6b2d3..8ee4e68ef 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -60,7 +60,7 @@ rfd = { workspace = true, features = ["file-handle-inner"] } wasm-bindgen-futures = "0.4" wasm-bindgen = "0.2" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "HtmlIFrameElement", "HtmlImageElement", "HtmlCollection", "Node", "EventTarget"] } [dev-dependencies] pdfcraft-xfa = { workspace = true } diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 3eb3786eb..dad044e16 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2288,6 +2288,12 @@ scroll speed Fast سريعة Greyscale تدرج رمادي Black and white أبيض وأسود This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… لا يقبل برنامج تشغيل هذه الطابعة إعدادات الطباعة على الوجهين من البرامج الأخرى، لذا يُطبَّق إعداده الخاص. غيّره من خصائص… + Browser's print dialog مربع حوار الطباعة في المتصفح + Copies, two-sided printing and colour are chosen in the browser's print dialog. يُختار عدد النسخ والطباعة على الوجهين والألوان في مربع حوار الطباعة في المتصفح. + Preparing {n} sheets for printing… جارٍ تجهيز {n} من الأوراق للطباعة… + Preparing sheet {done} of {total} for printing… جارٍ تجهيز الورقة {done} من {total} للطباعة… + Opening the browser's print dialog… جارٍ فتح مربع حوار الطباعة في المتصفح… + Couldn't print from the browser ({e}); downloaded {name} to print instead تعذّرت الطباعة من المتصفح ({e})؛ نُزّل {name} لطباعته بدلًا من ذلك Tools panel side جانب لوحة الأدوات Add comment إضافة تعليق Change tag properties تغيير خصائص الوسم diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index fbcce77ca..7a8cad067 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2230,6 +2230,12 @@ scroll speed Fast Висока Greyscale Нива на сивото Black and white Черно-бяло This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Драйверът на този принтер не приема настройки за двустранен печат от други програми и прилага собствената си настройка. Променете я от Свойства… + Browser's print dialog Диалог за печат на браузъра + Copies, two-sided printing and colour are chosen in the browser's print dialog. Броят копия, двустранният печат и цветът се избират в диалога за печат на браузъра. + Preparing {n} sheets for printing… Подготовка на листове за печат: {n}… + Preparing sheet {done} of {total} for printing… Подготовка за печат на лист {done} от {total}… + Opening the browser's print dialog… Отваряне на диалога за печат на браузъра… + Couldn't print from the browser ({e}); downloaded {name} to print instead Не може да се печата от браузъра ({e}); вместо това за печат е изтеглен {name} Tools panel side Страна на панела с инструменти Add comment Добавяне на коментар Change tag properties Промяна на свойствата на етикета diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 579695c0d..e9cc5339a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2341,6 +2341,12 @@ scroll speed Fast Rychlá This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Ovladač této tiskárny nepřijímá nastavení oboustranného tisku z jiných programů, proto platí jeho vlastní nastavení. Změňte ho ve Vlastnosti… This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Písmo tohoto pole neumí zobrazit „{c}“ ({code}), proto PdfCraft místo něj ukazuje ?. Hodnota se uloží a jiné prohlížeče PDF pole podle ní překreslí. The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. Písma, kterými PdfCraft píše, neumí zobrazit „{c}“ ({code}), proto se ve všech prohlížečích PDF zobrazí jako ?. Text se uloží. + Browser's print dialog Dialog tisku prohlížeče + Copies, two-sided printing and colour are chosen in the browser's print dialog. Počet kopií, oboustranný tisk a barva se volí v dialogu tisku prohlížeče. + Preparing {n} sheets for printing… Příprava {n} listů k tisku… + Preparing sheet {done} of {total} for printing… Příprava listu {done} z {total} k tisku… + Opening the browser's print dialog… Otevírá se dialog tisku prohlížeče… + Couldn't print from the browser ({e}); downloaded {name} to print instead Z prohlížeče nelze tisknout ({e}); místo toho byl k tisku stažen soubor {name} Tools panel side Strana panelu nástrojů Add comment Přidat komentář Change tag properties Změnit vlastnosti značky diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 399abbc09..2cc8417d9 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2268,6 +2268,12 @@ scroll speed Fast Schnell Greyscale Graustufen Black and white Schwarzweiß This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Der Treiber dieses Druckers übernimmt keine Einstellungen für beidseitigen Druck von anderen Programmen, daher gilt seine eigene Einstellung. Ändern unter Eigenschaften… + Browser's print dialog Druckdialog des Browsers + Copies, two-sided printing and colour are chosen in the browser's print dialog. Exemplare, beidseitiger Druck und Farbe werden im Druckdialog des Browsers gewählt. + Preparing {n} sheets for printing… {n} Blätter werden für den Druck vorbereitet… + Preparing sheet {done} of {total} for printing… Blatt {done} von {total} wird für den Druck vorbereitet… + Opening the browser's print dialog… Der Druckdialog des Browsers wird geöffnet… + Couldn't print from the browser ({e}); downloaded {name} to print instead Drucken aus dem Browser nicht möglich ({e}); stattdessen wurde {name} zum Drucken heruntergeladen Tools panel side Seite des Werkzeugbereichs Add comment Kommentar hinzufügen Change tag properties Tag-Eigenschaften ändern diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 567980f3f..af43d05d5 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2282,6 +2282,12 @@ scroll speed Fast Γρήγορη This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Το πρόγραμμα οδήγησης αυτού του εκτυπωτή δεν δέχεται ρυθμίσεις εκτύπωσης διπλής όψης από άλλα προγράμματα, οπότε ισχύει η δική του ρύθμιση. Αλλάξτε τη στις Ιδιότητες… This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Η γραμματοσειρά αυτού του πεδίου δεν μπορεί να σχεδιάσει το «{c}» ({code}), γι' αυτό το PdfCraft δείχνει ? στη θέση του. Η τιμή αποθηκεύεται και άλλα προγράμματα προβολής PDF επανασχεδιάζουν το πεδίο από αυτήν. The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. Οι γραμματοσειρές με τις οποίες γράφει το PdfCraft δεν μπορούν να σχεδιάσουν το «{c}» ({code}), γι' αυτό εμφανίζεται ως ? σε κάθε πρόγραμμα προβολής PDF. Το κείμενο αποθηκεύεται. + Browser's print dialog Παράθυρο εκτύπωσης του προγράμματος περιήγησης + Copies, two-sided printing and colour are chosen in the browser's print dialog. Τα αντίγραφα, η εκτύπωση διπλής όψης και το χρώμα επιλέγονται στο παράθυρο εκτύπωσης του προγράμματος περιήγησης. + Preparing {n} sheets for printing… Προετοιμασία {n} φύλλων για εκτύπωση… + Preparing sheet {done} of {total} for printing… Προετοιμασία φύλλου {done} από {total} για εκτύπωση… + Opening the browser's print dialog… Άνοιγμα του παραθύρου εκτύπωσης του προγράμματος περιήγησης… + Couldn't print from the browser ({e}); downloaded {name} to print instead Δεν ήταν δυνατή η εκτύπωση από το πρόγραμμα περιήγησης ({e}); έγινε λήψη του {name} για εκτύπωση Tools panel side Πλευρά πλαισίου εργαλείων Add comment Προσθήκη σχολίου Change tag properties Αλλαγή ιδιοτήτων ετικέτας diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index aaf63e8b5..7bf6fdfef 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2397,6 +2397,12 @@ scroll speed Fast Rápida Greyscale Escala de grises Black and white Blanco y negro This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… El controlador de esta impresora no acepta los ajustes de doble cara de otros programas, así que se aplica su propio ajuste. Cámbielo en Propiedades… + Browser's print dialog Diálogo de impresión del navegador + Copies, two-sided printing and colour are chosen in the browser's print dialog. Las copias, la impresión a doble cara y el color se eligen en el diálogo de impresión del navegador. + Preparing {n} sheets for printing… Preparando {n} hojas para imprimir… + Preparing sheet {done} of {total} for printing… Preparando la hoja {done} de {total} para imprimir… + Opening the browser's print dialog… Abriendo el diálogo de impresión del navegador… + Couldn't print from the browser ({e}); downloaded {name} to print instead No se pudo imprimir desde el navegador ({e}); se descargó {name} para imprimirlo Tools panel side Lado del panel de herramientas Add comment Añadir comentario Change tag properties Cambiar propiedades de la etiqueta diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 00e172ea7..9b5ff871f 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2267,6 +2267,12 @@ scroll speed Fast Rapide Greyscale Niveaux de gris Black and white Noir et blanc This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Le pilote de cette imprimante n'accepte pas les réglages recto verso des autres programmes ; son propre réglage s'applique. Modifiez-le dans Propriétés… + Browser's print dialog Boîte de dialogue d'impression du navigateur + Copies, two-sided printing and colour are chosen in the browser's print dialog. Le nombre de copies, l'impression recto verso et la couleur se choisissent dans la boîte de dialogue d'impression du navigateur. + Preparing {n} sheets for printing… Préparation de {n} feuilles pour l'impression… + Preparing sheet {done} of {total} for printing… Préparation de la feuille {done} sur {total} pour l'impression… + Opening the browser's print dialog… Ouverture de la boîte de dialogue d'impression du navigateur… + Couldn't print from the browser ({e}); downloaded {name} to print instead Impossible d'imprimer depuis le navigateur ({e}) ; {name} a été téléchargé pour être imprimé Tools panel side Côté du panneau Outils Add comment Ajouter un commentaire Change tag properties Modifier les propriétés de la balise diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index d64a1ccb6..1cbaaa6be 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2266,6 +2266,12 @@ scroll speed Fast Gyors Greyscale Szürkeárnyalatos Black and white Fekete-fehér This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… A nyomtató illesztőprogramja nem fogadja el más programoktól a kétoldalas nyomtatás beállítását, ezért a saját beállítása érvényes. Módosítás: Tulajdonságok… + Browser's print dialog A böngésző nyomtatási párbeszédablaka + Copies, two-sided printing and colour are chosen in the browser's print dialog. A példányszámot, a kétoldalas nyomtatást és a színt a böngésző nyomtatási párbeszédablakában lehet megadni. + Preparing {n} sheets for printing… {n} lap előkészítése nyomtatásra… + Preparing sheet {done} of {total} for printing… {done}/{total}. lap előkészítése nyomtatásra… + Opening the browser's print dialog… A böngésző nyomtatási párbeszédablakának megnyitása… + Couldn't print from the browser ({e}); downloaded {name} to print instead Nem sikerült nyomtatni a böngészőből ({e}); helyette a(z) {name} letöltődött nyomtatáshoz Tools panel side Az Eszközök panel oldala Add comment Megjegyzés hozzáadása Change tag properties Címke tulajdonságainak módosítása diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 534c93221..c68ac4f92 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2276,6 +2276,12 @@ scroll speed Fast Veloce Greyscale Scala di grigi Black and white Bianco e nero This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Il driver di questa stampante non accetta le impostazioni fronte-retro da altri programmi, quindi vale la sua impostazione. Modificala in Proprietà… + Browser's print dialog Finestra di stampa del browser + Copies, two-sided printing and colour are chosen in the browser's print dialog. Copie, stampa fronte/retro e colore si scelgono nella finestra di stampa del browser. + Preparing {n} sheets for printing… Preparazione di {n} fogli per la stampa… + Preparing sheet {done} of {total} for printing… Preparazione del foglio {done} di {total} per la stampa… + Opening the browser's print dialog… Apertura della finestra di stampa del browser… + Couldn't print from the browser ({e}); downloaded {name} to print instead Impossibile stampare dal browser ({e}); è stato scaricato {name} da stampare Tools panel side Lato del pannello Strumenti Add comment Aggiungi commento Change tag properties Modifica proprietà del tag diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 2aeca4311..8fb01868a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2267,6 +2267,12 @@ scroll speed Fast 速い Greyscale グレースケール Black and white 白黒 This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… このプリンターのドライバーは他のプログラムからの両面印刷の設定を受け付けないため、ドライバー自身の設定が使われます。変更先: プロパティ… + Browser's print dialog ブラウザーの印刷ダイアログ + Copies, two-sided printing and colour are chosen in the browser's print dialog. 部数、両面印刷、カラーはブラウザーの印刷ダイアログで選びます。 + Preparing {n} sheets for printing… {n} 枚のシートを印刷用に準備しています… + Preparing sheet {done} of {total} for printing… 印刷用にシート {done}/{total} を準備しています… + Opening the browser's print dialog… ブラウザーの印刷ダイアログを開いています… + Couldn't print from the browser ({e}); downloaded {name} to print instead ブラウザーから印刷できませんでした ({e})。代わりに印刷用の {name} をダウンロードしました Tools panel side ツールパネルの位置 Add comment コメントを追加 Change tag properties タグのプロパティを変更 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 38b802384..0254df6d7 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2264,6 +2264,12 @@ scroll speed Fast Жылдам This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Бұл принтердің драйвері басқа бағдарламалардан екі жақты басып шығару баптауларын қабылдамайды, сондықтан өз баптауы қолданылады. Оны мына жерден өзгертіңіз: Сипаттар… This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Бұл өрістің қаріпі «{c}» ({code}) таңбасын сала алмайды, сондықтан PdfCraft оның орнына ? көрсетеді. Мән сақталады, ал басқа PDF қарау құралдары өрісті соған қарап қайта салады. The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. PdfCraft жазатын қаріптер «{c}» ({code}) таңбасын сала алмайды, сондықтан ол барлық PDF қарау құралдарында ? болып көрінеді. Мәтін сақталады. + Browser's print dialog Браузердің басып шығару терезесі + Copies, two-sided printing and colour are chosen in the browser's print dialog. Көшірмелер саны, екі жақты басып шығару және түс браузердің басып шығару терезесінде таңдалады. + Preparing {n} sheets for printing… Басып шығаруға парақтар дайындалуда: {n}… + Preparing sheet {done} of {total} for printing… Басып шығаруға {total} парақтың {done}-сі дайындалуда… + Opening the browser's print dialog… Браузердің басып шығару терезесі ашылуда… + Couldn't print from the browser ({e}); downloaded {name} to print instead Браузерден басып шығару мүмкін болмады ({e}); оның орнына басып шығару үшін {name} жүктелді Tools panel side Құралдар панелінің жағы Add comment Түсініктеме қосу Change tag properties Тег қасиеттерін өзгерту diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index a1e0a7410..9d702d3c8 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2247,6 +2247,12 @@ scroll speed Fast Szybka Greyscale Skala szarości Black and white Czarno-biały This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Sterownik tej drukarki nie przyjmuje ustawień druku dwustronnego z innych programów, więc obowiązuje jego własne ustawienie. Zmień je w oknie Właściwości… + Browser's print dialog Okno drukowania przeglądarki + Copies, two-sided printing and colour are chosen in the browser's print dialog. Liczbę kopii, druk dwustronny i kolor wybiera się w oknie drukowania przeglądarki. + Preparing {n} sheets for printing… Przygotowywanie arkuszy do druku: {n}… + Preparing sheet {done} of {total} for printing… Przygotowywanie arkusza {done} z {total} do druku… + Opening the browser's print dialog… Otwieranie okna drukowania przeglądarki… + Couldn't print from the browser ({e}); downloaded {name} to print instead Nie można drukować z przeglądarki ({e}); zamiast tego pobrano plik {name} do wydruku Tools panel side Strona panelu narzędzi Add comment Dodaj komentarz Change tag properties Zmień właściwości znacznika diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index c5df67d72..d475ddb9a 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2281,6 +2281,12 @@ scroll speed Fast Rápida Greyscale Escala de cinza Black and white Preto e branco This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… O driver desta impressora não aceita configurações de frente e verso de outros programas, então vale a configuração dele. Altere em Propriedades… + Browser's print dialog Caixa de diálogo de impressão do navegador + Copies, two-sided printing and colour are chosen in the browser's print dialog. Cópias, impressão frente e verso e cor são escolhidas na caixa de diálogo de impressão do navegador. + Preparing {n} sheets for printing… Preparando {n} folhas para impressão… + Preparing sheet {done} of {total} for printing… Preparando a folha {done} de {total} para impressão… + Opening the browser's print dialog… Abrindo a caixa de diálogo de impressão do navegador… + Couldn't print from the browser ({e}); downloaded {name} to print instead Não foi possível imprimir pelo navegador ({e}); {name} foi baixado para impressão Tools panel side Lado do painel Ferramentas Add comment Adicionar comentário Change tag properties Alterar propriedades da tag diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9cd5f1245..1617e9692 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2180,6 +2180,12 @@ scroll speed Fast Высокая Greyscale Оттенки серого Black and white Чёрно-белый This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Драйвер этого принтера не принимает настройки двусторонней печати от других программ, поэтому действует его собственная настройка. Измените её в окне Свойства… + Browser's print dialog Диалог печати браузера + Copies, two-sided printing and colour are chosen in the browser's print dialog. Число копий, двустороннюю печать и цвет выбирают в диалоге печати браузера. + Preparing {n} sheets for printing… Подготовка листов к печати: {n}… + Preparing sheet {done} of {total} for printing… Подготовка к печати листа {done} из {total}… + Opening the browser's print dialog… Открывается диалог печати браузера… + Couldn't print from the browser ({e}); downloaded {name} to print instead Не удалось напечатать из браузера ({e}); вместо этого для печати скачан файл {name} Tools panel side Сторона панели инструментов Add comment Добавить комментарий Change tag properties Изменить свойства тега diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 6b8d1d32b..761b2fb5a 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2271,6 +2271,12 @@ scroll speed Fast Hitro This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Gonilnik tega tiskalnika ne sprejema nastavitev obojestranskega tiskanja iz drugih programov, zato velja njegova lastna nastavitev. Spremenite jo v Lastnosti… This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Pisava tega polja ne more prikazati »{c}« ({code}), zato PdfCraft namesto njega prikaže ?. Vrednost se shrani, drugi pregledovalniki PDF pa polje znova narišejo iz nje. The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. Pisave, s katerimi piše PdfCraft, ne morejo prikazati »{c}« ({code}), zato se v vseh pregledovalnikih PDF prikaže kot ?. Besedilo se shrani. + Browser's print dialog Pogovorno okno za tiskanje v brskalniku + Copies, two-sided printing and colour are chosen in the browser's print dialog. Število kopij, obojestransko tiskanje in barvo izberete v pogovornem oknu za tiskanje v brskalniku. + Preparing {n} sheets for printing… Priprava listov za tiskanje: {n}… + Preparing sheet {done} of {total} for printing… Priprava lista {done} od {total} za tiskanje… + Opening the browser's print dialog… Odpiranje pogovornega okna za tiskanje v brskalniku… + Couldn't print from the browser ({e}); downloaded {name} to print instead Tiskanje iz brskalnika ni mogoče ({e}); namesto tega je bila za tiskanje prenesena datoteka {name} Tools panel side Stran podokna z orodji Add comment Dodaj komentar Change tag properties Spremeni lastnosti oznake diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index ca89bc678..aa3b64711 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2180,6 +2180,12 @@ scroll speed Fast వేగంగా Original అసలు Greyscale గ్రేస్కేల్ Black and white నలుపు తెలుపు + Browser's print dialog బ్రౌజర్ ముద్రణ డైలాగ్ + Copies, two-sided printing and colour are chosen in the browser's print dialog. కాపీలు, రెండు వైపుల ముద్రణ మరియు రంగును బ్రౌజర్ ముద్రణ డైలాగ్‌లో ఎంచుకుంటారు. + Preparing {n} sheets for printing… ముద్రణ కోసం {n} షీట్‌లను సిద్ధం చేస్తోంది… + Preparing sheet {done} of {total} for printing… ముద్రణ కోసం {total}లో షీట్ {done}ను సిద్ధం చేస్తోంది… + Opening the browser's print dialog… బ్రౌజర్ ముద్రణ డైలాగ్‌ను తెరుస్తోంది… + Couldn't print from the browser ({e}); downloaded {name} to print instead బ్రౌజర్ నుండి ముద్రించలేకపోయింది ({e}); బదులుగా ముద్రించడానికి {name}ను డౌన్‌లోడ్ చేసింది Tools panel side టూల్స్ ప్యానెల్ వైపు Add comment వ్యాఖ్యను జోడించు Change tag properties ట్యాగ్ లక్షణాలను మార్చు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 86c359c8d..b1453b2fc 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2262,6 +2262,12 @@ scroll speed Fast Hızlı This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Bu yazıcının sürücüsü diğer programlardan gelen çift taraflı yazdırma ayarlarını kabul etmez, bu yüzden kendi ayarı geçerlidir. Değiştirmek için: Özellikler… This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Bu alanın yazı tipi “{c}” ({code}) karakterini çizemiyor, bu yüzden PdfCraft yerine ? gösteriyor. Değer kaydedilir ve diğer PDF görüntüleyiciler alanı ondan yeniden çizer. The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. PdfCraft'ın yazdığı yazı tipleri “{c}” ({code}) karakterini çizemiyor, bu yüzden her PDF görüntüleyicide ? olarak görünür. Metin kaydedilir. + Browser's print dialog Tarayıcının yazdırma penceresi + Copies, two-sided printing and colour are chosen in the browser's print dialog. Kopya sayısı, çift taraflı yazdırma ve renk tarayıcının yazdırma penceresinde seçilir. + Preparing {n} sheets for printing… Yazdırma için {n} sayfa hazırlanıyor… + Preparing sheet {done} of {total} for printing… Yazdırma için sayfa {done}/{total} hazırlanıyor… + Opening the browser's print dialog… Tarayıcının yazdırma penceresi açılıyor… + Couldn't print from the browser ({e}); downloaded {name} to print instead Tarayıcıdan yazdırılamadı ({e}); bunun yerine yazdırmak için {name} indirildi Tools panel side Araçlar panelinin yeri Add comment Yorum ekle Change tag properties Etiket özelliklerini değiştir diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index cb92ce2c3..286243633 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2289,6 +2289,12 @@ scroll speed Fast Швидка Greyscale Відтінки сірого Black and white Чорно-білий This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Драйвер цього принтера не приймає налаштування двобічного друку від інших програм, тому діє його власне налаштування. Змініть його у вікні Властивості… + Browser's print dialog Діалог друку браузера + Copies, two-sided printing and colour are chosen in the browser's print dialog. Кількість копій, двосторонній друк і колір вибирають у діалозі друку браузера. + Preparing {n} sheets for printing… Підготовка аркушів до друку: {n}… + Preparing sheet {done} of {total} for printing… Підготовка до друку аркуша {done} з {total}… + Opening the browser's print dialog… Відкривається діалог друку браузера… + Couldn't print from the browser ({e}); downloaded {name} to print instead Не вдалося надрукувати з браузера ({e}); натомість для друку завантажено файл {name} Tools panel side Бік панелі інструментів Add comment Додати коментар Change tag properties Змінити властивості тегу diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 83231dd56..80a1c00ea 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2231,6 +2231,12 @@ scroll speed Fast 快 Greyscale 灰度 Black and white 黑白 This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… 此打印机的驱动程序不接受其他程序的双面打印设置,因此使用其自身设置。更改位置:属性… + Browser's print dialog 浏览器的打印对话框 + Copies, two-sided printing and colour are chosen in the browser's print dialog. 份数、双面打印和颜色在浏览器的打印对话框中选择。 + Preparing {n} sheets for printing… 正在准备 {n} 张纸用于打印… + Preparing sheet {done} of {total} for printing… 正在准备打印第 {done} 张,共 {total} 张… + Opening the browser's print dialog… 正在打开浏览器的打印对话框… + Couldn't print from the browser ({e}); downloaded {name} to print instead 无法从浏览器打印({e});已改为下载 {name} 以便打印 Tools panel side 工具面板位置 Add comment 添加注释 Change tag properties 更改标签属性 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 67d24c587..70a6c2309 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2141,6 +2141,12 @@ scroll speed Fast 快 Greyscale 灰階 Black and white 黑白 This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… 此印表機的驅動程式不接受其他程式的雙面列印設定,因此使用其自身設定。變更位置:屬性… + Browser's print dialog 瀏覽器的列印對話框 + Copies, two-sided printing and colour are chosen in the browser's print dialog. 份數、雙面列印和色彩在瀏覽器的列印對話框中選擇。 + Preparing {n} sheets for printing… 正在準備 {n} 張紙以供列印… + Preparing sheet {done} of {total} for printing… 正在準備列印第 {done} 張,共 {total} 張… + Opening the browser's print dialog… 正在開啟瀏覽器的列印對話框… + Couldn't print from the browser ({e}); downloaded {name} to print instead 無法從瀏覽器列印({e});已改為下載 {name} 以供列印 Tools panel side 工具面板位置 Add comment 新增註解 Change tag properties 變更標籤屬性 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5d94e2892..106225d33 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -93,6 +93,7 @@ pub mod prepare; mod print_ui; mod signature_drag; pub mod units; +mod web_print; pub use print_ui::{Handling as PrintHandling, PrintDraft, PrinterSource, Which as PrintWhich, system_printers}; mod redact_ui; pub use redact_ui::{HiddenDraft, PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft, SearchMode as RedactSearchMode}; diff --git a/crates/ui-egui/src/print_ui.rs b/crates/ui-egui/src/print_ui.rs index c305b6d67..129dd080c 100644 --- a/crates/ui-egui/src/print_ui.rs +++ b/crates/ui-egui/src/print_ui.rs @@ -195,6 +195,34 @@ pub(crate) struct PrintTasks { printers: Option>>, #[cfg(not(target_arch = "wasm32"))] jobs: Vec<(String, std::sync::mpsc::Receiver>)>, + /// The browser print being drawn, a sheet a frame (`web_print`). + #[cfg(target_arch = "wasm32")] + web_job: Option, + /// What went wrong after the browser print page was handed over, with the print-ready PDF + /// to download instead. + #[cfg(target_arch = "wasm32")] + web_failed: std::sync::Arc>>, + /// The last browser print's PDF and name, for that fallback. + #[cfg(target_arch = "wasm32")] + web_last: Option<(String, std::sync::Arc>)>, +} + +/// The printers to offer when no [`PrinterSource`] is set: the spooler's, or in a browser its +/// print dialog. +fn listed_printers() -> Vec { + #[cfg(target_arch = "wasm32")] + { + vec![spool::Printer { name: crate::web_print::BROWSER_PRINTER.to_string(), default: true, two_sided: None }] + } + #[cfg(not(target_arch = "wasm32"))] + { + spool::printers() + } +} + +/// A printer's name as the dialog shows it. +fn printer_label(name: &str) -> String { + if name == crate::web_print::BROWSER_PRINTER { crate::web_print::browser_printer_label().to_string() } else { name.to_string() } } /// The printer the dialog preselects: the system default, else the first. @@ -212,7 +240,7 @@ impl PdfCraftApp { self.list_printers_in_background(source); (self.print_draft.printers.clone(), true) } - _ => (spool::printers(), false), + _ => (listed_printers(), false), }; let default = default_printer(&printers); let current = self.views[i].current; @@ -260,6 +288,8 @@ impl PdfCraftApp { /// Pick up a printer list or a spooled job that finished (each frame). pub(crate) fn poll_print(&mut self) { + #[cfg(target_arch = "wasm32")] + self.poll_web_print(); #[cfg(not(target_arch = "wasm32"))] { if let Some(rx) = &self.print_tasks.printers { @@ -332,6 +362,17 @@ impl PdfCraftApp { } }; match self.print_draft.printer.clone() { + #[cfg(target_arch = "wasm32")] + Some(printer) if printer == crate::web_print::BROWSER_PRINTER => { + let sizes: Vec<(f64, f64)> = doc.info.pages.iter().map(|p| (f64::from(p.width), f64::from(p.height))).collect(); + match print::layout(&sizes, &settings) { + Ok(sheets) => self.print_in_browser(name, bytes, sheets.iter().map(|s| s.size).collect()), + Err(e) => { + self.notify_error(e); + false + } + } + } Some(printer) => self.send_to_printer(printer, bytes, self.print_draft.job(&name)), None => self.save_print_pdf(&name, bytes), } @@ -364,6 +405,75 @@ impl PdfCraftApp { ok } + /// Print through the browser's print dialog: start drawing `sheets` (the pages of the + /// print-ready `bytes`), a sheet a frame in [`PdfCraftApp::poll_print`]. A print still being + /// prepared is replaced. The dialog closes at once. + #[cfg(target_arch = "wasm32")] + fn print_in_browser(&mut self, name: String, bytes: Vec, sheets: Vec<(f64, f64)>) -> bool { + match crate::web_print::WebPrintJob::new(bytes, sheets, name) { + Ok(job) => { + let n = job.total().to_string(); + self.print_tasks.web_job = Some(job); + self.notify_fmt("Preparing {n} sheets for printing…", &[("n", &n)]); + if let Some(ctx) = &self.ctx { + ctx.request_repaint(); + } + true + } + Err(e) => { + self.notify_error(e); + false + } + } + } + + /// Draw the next sheet of the browser print, and hand the finished page to the browser. + #[cfg(target_arch = "wasm32")] + fn poll_web_print(&mut self) { + let failed: Vec = self.print_tasks.web_failed.lock().map(|mut q| std::mem::take(&mut *q)).unwrap_or_default(); + if let Some(e) = failed.into_iter().next() { + self.web_print_failed(&e); + } + let Some(job) = self.print_tasks.web_job.as_mut() else { return }; + if !job.finished() { + if let Err(e) = job.step(&mut crate::web_print::blob_url) { + self.print_tasks.web_job = None; + self.notify_error(e); + return; + } + let (done, total) = (job.done().to_string(), job.total().to_string()); + self.notify_fmt("Preparing sheet {done} of {total} for printing…", &[("done", &done), ("total", &total)]); + if let Some(ctx) = &self.ctx { + ctx.request_repaint(); + } + return; + } + let Some(mut job) = self.print_tasks.web_job.take() else { return }; + let html = job.html(); + let urls = job.take_urls(); + self.print_tasks.web_last = Some((job.name.clone(), job.pdf.clone())); + match crate::web_print::print_in_iframe(&html, urls, self.print_tasks.web_failed.clone(), self.ctx.clone()) { + Ok(()) => self.notify_tr("Opening the browser's print dialog…"), + Err(e) => self.web_print_failed(&e), + } + } + + /// The browser wouldn't print (a sandboxed frame, a picture it couldn't decode): offer the + /// print-ready PDF as a download instead, so the work isn't lost. + #[cfg(target_arch = "wasm32")] + fn web_print_failed(&mut self, error: &str) { + let Some((name, pdf)) = self.print_tasks.web_last.take() else { + self.notify_error(error); + return; + }; + let stem = name.trim_end_matches(".pdf").trim_end_matches(".PDF"); + let file = format!("{stem} (print).pdf"); + match crate::editing::download(&file, &pdf) { + Ok(()) => self.notify_fmt("Couldn't print from the browser ({e}); downloaded {name} to print instead", &[("e", error), ("name", &file)]), + Err(_) => self.notify_error(error), + } + } + /// Print ▸ Save as PDF on the desktop: write to `save_override` (tests and automation), or /// ask where and write on a later frame. Returns `true` once written, or once the save picker /// is showing. @@ -551,14 +661,21 @@ pub(crate) fn body( egui::Grid::new("print-top").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { ui.label(tl!("Printer:")); let before = d.printer.clone(); + // The browser's print dialog sets copies, two-sided printing and colour itself. + let browser = d.printer.as_deref() == Some(crate::web_print::BROWSER_PRINTER); ui.horizontal(|ui| { - let shown = d.printer.clone().unwrap_or_else(|| tl!("Save as PDF").to_string()); + let shown = d.printer.as_deref().map_or_else(|| tl!("Save as PDF").to_string(), printer_label); egui::ComboBox::from_id_salt("printer").selected_text(shown).width(260.0).show_ui(ui, |ui| { if d.printers_loading { ui.add_enabled(false, egui::Label::new(tl!("Looking for printers…"))); } for p in &d.printers { - let label = if p.default { crate::i18n::fmt(tl!("{name} (default)"), &[("name", &p.name)]) } else { p.name.clone() }; + let name = printer_label(&p.name); + let label = if p.default && p.name != crate::web_print::BROWSER_PRINTER { + crate::i18n::fmt(tl!("{name} (default)"), &[("name", &name)]) + } else { + name + }; ui.selectable_value(&mut d.printer, Some(p.name.clone()), label); } ui.selectable_value(&mut d.printer, None, tl!("Save as PDF")); @@ -568,7 +685,7 @@ pub(crate) fn body( } else { tl!("The printer driver's own settings for this print: paper tray, paper type, finishing and more.") }; - let properties = ui.add_enabled(d.printer.is_some(), egui::Button::new(tl!("Properties…"))).on_hover_text(tip); + let properties = ui.add_enabled(d.printer.is_some() && !browser, egui::Button::new(tl!("Properties…"))).on_hover_text(tip); if properties.clicked() && let Some(p) = d.printer.clone() { @@ -590,24 +707,28 @@ pub(crate) fn body( } ui.end_row(); ui.label(tl!("Copies:")); - ui.horizontal(|ui| { - ui.add(egui::DragValue::new(&mut d.copies).range(1..=999)); - ui.add(widgets::checkbox(&mut d.collate, tl!("Collate"))); - ui.add(widgets::checkbox(&mut d.grayscale, tl!("Print in grayscale"))); + ui.add_enabled_ui(!browser, |ui| { + ui.horizontal(|ui| { + ui.add(egui::DragValue::new(&mut d.copies).range(1..=999)); + ui.add(widgets::checkbox(&mut d.collate, tl!("Collate"))); + ui.add(widgets::checkbox(&mut d.grayscale, tl!("Print in grayscale"))); + }); }); ui.end_row(); ui.label(tl!("Two-sided:")); - combo( - ui, - "duplex", - &mut d.duplex, - &[ - (spool::Duplex::Off, tl!("Off")), - (spool::Duplex::LongEdge, tl!("Flip on long edge")), - (spool::Duplex::ShortEdge, tl!("Flip on short edge")), - ], - 160.0, - ); + ui.add_enabled_ui(!browser, |ui| { + combo( + ui, + "duplex", + &mut d.duplex, + &[ + (spool::Duplex::Off, tl!("Off")), + (spool::Duplex::LongEdge, tl!("Flip on long edge")), + (spool::Duplex::ShortEdge, tl!("Flip on short edge")), + ], + 160.0, + ); + }); ui.end_row(); ui.label(tl!("Paper:")); let paper_name = PAPERS.get(d.paper).map_or("US Letter", |p| p.0); @@ -625,6 +746,9 @@ pub(crate) fn body( { ui.label(egui::RichText::new(tl!("This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties…")).small().color(t.text_muted)); } + if d.printer.as_deref() == Some(crate::web_print::BROWSER_PRINTER) { + ui.label(egui::RichText::new(tl!("Copies, two-sided printing and colour are chosen in the browser's print dialog.")).small().color(t.text_muted)); + } if let Some(e) = &d.driver_error { ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Couldn't open the printer's preferences: {e}"), &[("e", e)])).small().color(t.text_muted)); } diff --git a/crates/ui-egui/src/web_print.rs b/crates/ui-egui/src/web_print.rs new file mode 100644 index 000000000..6af878daf --- /dev/null +++ b/crates/ui-egui/src/web_print.rs @@ -0,0 +1,453 @@ +//! Printing from the browser (docs/gaps.md gap 4). A web page can't reach a spooler, so the +//! print-ready PDF is drawn sheet by sheet with PdfCraft's own renderer, one sheet a frame (the +//! web build has no threads), and the pictures go to the browser's print dialog in a hidden +//! iframe, one sheet per printed page. That prints the same in every browser, with or without a +//! PDF viewer of its own, and matches the preview. Copies, two-sided printing and colour are +//! chosen in the browser's dialog. +//! +//! The planning, the print page and the job are plain Rust and tested on every target; only +//! handing the pictures to the browser (`blob_url`, `print_in_iframe`) is wasm-only. + +// Outside the browser only the tests drive the job; the app itself prints through the spooler. +#![cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] + +use pdfcraft_engine::export::{ExportSource, Exporter}; + +/// The printer the web build lists: the browser's print dialog, which reaches the printers the +/// operating system knows. An id, never shown; the dialog shows [`browser_printer_label`]. +pub(crate) const BROWSER_PRINTER: &str = "\u{1}browser"; + +/// The browser printer's name in the UI language. +pub(crate) fn browser_printer_label() -> &'static str { + tl!("Browser's print dialog") +} + +/// The most sheets a browser print takes: each is a picture held by the page until the dialog +/// closes. Longer jobs are saved as the print-ready PDF and printed from a PDF viewer. +pub(crate) const MAX_SHEETS: usize = 1000; +/// The most pixels one sheet is drawn with (the Windows spooler's limit too): about A0 at 200 dpi. +pub(crate) const MAX_SHEET_PIXELS: f64 = 64.0e6; +/// The most PNG bytes one job may hold before it stops, so a long job of busy pages can't exhaust +/// the tab's memory. +pub(crate) const MAX_JOB_BYTES: u64 = 1 << 30; +/// The lowest resolution a sheet is drawn at, whatever its size. +const MIN_DPI: f64 = 72.0; + +/// The resolution each sheet (`(width, height)` in points) is drawn at: 300 dpi for up to 100 +/// sheets, 200 up to 300 and 150 up to [`MAX_SHEETS`], lowered for a sheet that would exceed +/// [`MAX_SHEET_PIXELS`]. Refused for no sheets, too many, or a sheet without a usable size. +pub(crate) fn plan_dpi(sheets: &[(f64, f64)]) -> Result, String> { + let base: f64 = match sheets.len() { + 0 => return Err("there are no sheets to print".into()), + 1..=100 => 300.0, + 101..=300 => 200.0, + n if n <= MAX_SHEETS => 150.0, + n => { + return Err(format!( + "{n} sheets are too many to print from the browser (at most {MAX_SHEETS}); save the print-ready PDF and print it from a PDF viewer" + )); + } + }; + sheets + .iter() + .enumerate() + .map(|(i, &(w, h))| { + if !(w.is_finite() && h.is_finite()) || w < 1.0 || h < 1.0 { + return Err(format!("sheet {} has no usable size", i + 1)); + } + let square_inches = (w / 72.0) * (h / 72.0); + let cap = (MAX_SHEET_PIXELS / square_inches).sqrt(); + let dpi = if cap.is_finite() { base.min(cap) } else { base }; + Ok(dpi.max(MIN_DPI)) + }) + .collect() +} + +/// A length in points for CSS, rounded to 1/1000 pt. +fn pt(v: f64) -> String { + let r = (v * 1000.0).round() / 1000.0; + format!("{r}pt") +} + +/// `"` `&` `<` `>` escaped for an HTML attribute value. +fn attr(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '"' => out.push_str("""), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + c => out.push(c), + } + } + out +} + +/// Whether two sizes in points are the same within half a point. +fn same_size(a: (f64, f64), b: (f64, f64)) -> bool { + (a.0 - b.0).abs() <= 0.5 && (a.1 - b.1).abs() <= 0.5 +} + +/// The page the browser prints: each sheet's picture (`urls`, in order) filling a printed page +/// with no margin. The paper is the first sheet's size (the default `@page`). A sheet of that +/// size turned sideways (a landscape sheet among portrait ones) is turned 90° counter-clockwise +/// onto it, as a printer driver does, because browsers print every page at the default size +/// whatever named pages ask for (Chromium does). Any other size gets a named `@page` of its own, +/// which browsers that support it honour. Only the last sheet has no page break after it, so no +/// blank page follows. +pub(crate) fn print_document_html(sheets: &[(f64, f64)], urls: &[String]) -> String { + let paper = sheets.first().copied().unwrap_or((612.0, 792.0)); + let (pw, ph) = (pt(paper.0), pt(paper.1)); + let mut css = format!( + "html,body{{margin:0;padding:0;background:#fff}}@page{{size:{pw} {ph};margin:0}}\ + div{{position:relative;overflow:hidden;margin:0;padding:0;width:{pw};height:{ph};break-after:page;page-break-after:always}}\ + div:last-child{{break-after:auto;page-break-after:auto}}img{{display:block;position:absolute;left:0;top:0;margin:0;padding:0;border:0}}\ + .turned img{{width:{ph};height:{pw};transform-origin:0 0;transform:translate(0,{ph}) rotate(-90deg)}}" + ); + // Sizes other than the paper's, each a named page. + let mut others: Vec<(f64, f64)> = Vec::new(); + let mut html_sheets = String::new(); + for (&size, url) in sheets.iter().zip(urls) { + let src = attr(url); + if same_size(size, paper) { + html_sheets.push_str(&format!("
\"\"
")); + } else if same_size((size.1, size.0), paper) { + html_sheets.push_str(&format!("
\"\"
")); + } else { + let k = match others.iter().position(|o| same_size(*o, size)) { + Some(k) => k, + None => { + let (w, h) = (pt(size.0), pt(size.1)); + css.push_str(&format!("@page s{k}{{size:{w} {h};margin:0}}div.s{k}{{page:s{k};width:{w};height:{h}}}", k = others.len())); + others.push(size); + others.len() - 1 + } + }; + let (w, h) = (pt(size.0), pt(size.1)); + html_sheets.push_str(&format!("
\"\"
")); + } + } + format!("PdfCraft{html_sheets}") +} + +/// A browser print being prepared: the print-ready PDF's sheets drawn one [`WebPrintJob::step`] +/// at a time and handed to a sink (on the web, [`blob_url`]), which answers with the picture's URL. +pub(crate) struct WebPrintJob { + exporter: Exporter, + /// The print-ready PDF, kept for the download fallback when the browser won't print. + pub pdf: std::sync::Arc>, + pub name: String, + sheets: Vec<(f64, f64)>, + dpi: Vec, + urls: Vec, + bytes: u64, + /// [`MAX_JOB_BYTES`]; lower in tests. + max_bytes: u64, +} + +impl WebPrintJob { + /// A job for `pdf`, whose pages are `sheets` (in points, from `print::layout`). + pub fn new(pdf: Vec, sheets: Vec<(f64, f64)>, name: String) -> Result { + let dpi = plan_dpi(&sheets)?; + let pdf = std::sync::Arc::new(pdf); + let source = ExportSource { + bytes: pdf.clone(), + config: Default::default(), + pages: sheets.len(), + sizes: sheets.iter().map(|&(w, h)| (w as f32, h as f32)).collect(), + }; + Ok(Self { exporter: Exporter::from_source(source), pdf, name, sheets, dpi, urls: Vec::new(), bytes: 0, max_bytes: MAX_JOB_BYTES }) + } + + pub fn total(&self) -> usize { + self.sheets.len() + } + + pub fn done(&self) -> usize { + self.urls.len() + } + + pub fn finished(&self) -> bool { + self.urls.len() >= self.sheets.len() + } + + /// Draw the next sheet and give its PNG to `sink`. Errors end the job. + pub fn step(&mut self, sink: &mut dyn FnMut(Vec) -> Result) -> Result<(), String> { + let i = self.urls.len(); + let Some(&dpi) = self.dpi.get(i) else { return Ok(()) }; + let png = self.exporter.png(i, dpi)?; + self.bytes = self.bytes.saturating_add(png.len() as u64); + if self.bytes > self.max_bytes { + return Err(format!( + "the sheets need more than {} MB as pictures, too much to print from the browser; save the print-ready PDF and print it from a PDF viewer", + self.max_bytes >> 20 + )); + } + let url = sink(png)?; + self.urls.push(url); + Ok(()) + } + + /// The page to print, once [`WebPrintJob::finished`]. + pub fn html(&self) -> String { + print_document_html(&self.sheets, &self.urls) + } + + /// The pictures' URLs, now owned by the caller (who revokes them). + pub fn take_urls(&mut self) -> Vec { + std::mem::take(&mut self.urls) + } +} + +impl Drop for WebPrintJob { + /// A job dropped before printing (cancelled, failed, replaced) lets go of its pictures. + fn drop(&mut self) { + revoke(&std::mem::take(&mut self.urls)); + } +} + +/// Let go of pictures handed out by [`blob_url`]. +fn revoke(urls: &[String]) { + #[cfg(target_arch = "wasm32")] + for url in urls { + let _ = web_sys::Url::revoke_object_url(url); + } + #[cfg(not(target_arch = "wasm32"))] + let _ = urls; +} + +/// A JavaScript exception as text: an error's `message` (read as a property, since an error from +/// the print frame isn't an `instanceof Error` here), a thrown string, else its debug form. +#[cfg(target_arch = "wasm32")] +fn js_error(e: wasm_bindgen::JsValue) -> String { + if let Some(s) = e.as_string() { + return s; + } + js_sys::Reflect::get(&e, &wasm_bindgen::JsValue::from_str("message")) + .ok() + .and_then(|m| m.as_string()) + .filter(|m| !m.is_empty()) + .unwrap_or_else(|| format!("{e:?}")) +} + +/// A PNG as a `blob:` URL. The bytes move out of wasm memory into the browser's. +#[cfg(target_arch = "wasm32")] +pub(crate) fn blob_url(png: Vec) -> Result { + let err = js_error; + let array = js_sys::Uint8Array::from(png.as_slice()); + drop(png); + let opts = web_sys::BlobPropertyBag::new(); + opts.set_type("image/png"); + let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&js_sys::Array::of1(&array), &opts).map_err(err)?; + web_sys::Url::create_object_url_with_blob(&blob).map_err(err) +} + +#[cfg(target_arch = "wasm32")] +thread_local! { + /// The iframe of the last browser print and its pictures, removed when the browser says the + /// dialog closed, or at the latest when the next print starts. + static LAST_FRAME: std::cell::RefCell)>> = const { std::cell::RefCell::new(None) }; +} + +/// Remove the last print's iframe and let go of its pictures. +#[cfg(target_arch = "wasm32")] +fn clear_last_frame() { + if let Some((frame, urls)) = LAST_FRAME.with(|f| f.borrow_mut().take()) { + frame.remove(); + revoke(&urls); + } +} + +/// Open the browser's print dialog on `html` in a hidden iframe once its pictures (`urls`) are +/// decoded. What goes wrong after this returns (a picture that won't decode, a browser that +/// refuses to print from a frame) is pushed to `failed`, for the app to report. +#[cfg(target_arch = "wasm32")] +pub(crate) fn print_in_iframe( + html: &str, + urls: Vec, + failed: std::sync::Arc>>, + ctx: Option, +) -> Result<(), String> { + use wasm_bindgen::JsCast; + let err = js_error; + clear_last_frame(); + let document = web_sys::window().and_then(|w| w.document()).ok_or("no document")?; + let body = document.body().ok_or("no document body")?; + let frame: web_sys::HtmlIFrameElement = document.create_element("iframe").map_err(err)?.dyn_into().map_err(|_| "not an iframe")?; + // Not `display: none` or hidden: some browsers print nothing from a frame that isn't laid out. + frame.set_attribute("style", "position:fixed;right:0;bottom:0;width:0;height:0;border:0;opacity:0;pointer-events:none").map_err(err)?; + frame.set_attribute("aria-hidden", "true").map_err(err)?; + frame.set_attribute("tabindex", "-1").map_err(err)?; + let on_load = { + let frame = frame.clone(); + wasm_bindgen::closure::Closure::once_into_js(move || { + wasm_bindgen_futures::spawn_local(async move { + let report = |e: String| { + if let Ok(mut q) = failed.lock() { + q.push(e); + } + if let Some(ctx) = &ctx { + ctx.request_repaint(); + } + }; + let (Some(window), Some(doc)) = (frame.content_window(), frame.content_document()) else { + report("the print page didn't load".into()); + return; + }; + let images = doc.images(); + for i in 0..images.length() { + let Some(img) = images.item(i).and_then(|e| e.dyn_into::().ok()) else { continue }; + if let Err(e) = wasm_bindgen_futures::JsFuture::from(img.decode()).await { + report(format!("sheet {} couldn't be prepared for printing: {}", i + 1, err(e))); + return; + } + } + // Clean up when the dialog closes; browsers that don't say so are cleaned up by + // the next print. + let after = wasm_bindgen::closure::Closure::once_into_js(clear_last_frame); + let _ = window.add_event_listener_with_callback("afterprint", after.unchecked_ref()); + if let Err(e) = window.print() { + report(format!("the browser wouldn't open its print dialog: {}", err(e))); + } + }); + }) + }; + frame.add_event_listener_with_callback("load", on_load.unchecked_ref()).map_err(err)?; + frame.set_srcdoc(html); + body.append_child(&frame).map_err(err)?; + LAST_FRAME.with(|f| *f.borrow_mut() = Some((frame, urls))); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const LETTER: (f64, f64) = (612.0, 792.0); + + #[test] + fn resolution_falls_with_the_sheet_count_and_long_jobs_are_refused() { + assert_eq!(plan_dpi(&[LETTER]).unwrap(), vec![300.0]); + assert_eq!(plan_dpi(&vec![LETTER; 100]).unwrap()[99], 300.0); + assert_eq!(plan_dpi(&vec![LETTER; 101]).unwrap()[0], 200.0); + assert_eq!(plan_dpi(&vec![LETTER; 300]).unwrap()[0], 200.0); + assert_eq!(plan_dpi(&vec![LETTER; 301]).unwrap()[0], 150.0); + assert_eq!(plan_dpi(&vec![LETTER; MAX_SHEETS]).unwrap().len(), MAX_SHEETS); + let e = plan_dpi(&vec![LETTER; MAX_SHEETS + 1]).unwrap_err(); + assert!(e.contains("save the print-ready PDF"), "{e}"); + assert!(plan_dpi(&[]).is_err()); + } + + #[test] + fn large_sheets_are_drawn_within_the_pixel_cap_and_bad_sizes_are_refused() { + // ARCH E, 36 × 48 inches: 300 dpi would be 155 megapixels. + let dpi = plan_dpi(&[(36.0 * 72.0, 48.0 * 72.0)]).unwrap()[0]; + assert!(dpi < 300.0 && dpi > 150.0, "{dpi}"); + let pixels = (36.0 * dpi) * (48.0 * dpi); + assert!(pixels <= MAX_SHEET_PIXELS * 1.0001, "{pixels}"); + // A huge sheet still gets a usable floor rather than nothing. + assert_eq!(plan_dpi(&[(14_400.0, 14_400.0)]).unwrap()[0], MIN_DPI); + for bad in [(f64::NAN, 792.0), (612.0, f64::INFINITY), (0.0, 792.0), (-612.0, 792.0)] { + assert!(plan_dpi(&[LETTER, bad]).unwrap_err().contains("sheet 2")); + } + } + + #[test] + fn the_print_page_turns_sideways_sheets_names_other_sizes_and_ends_without_a_blank_page() { + const A4: (f64, f64) = (595.28, 841.89); + let sheets = [LETTER, (792.0, 612.0), A4, (612.2, 791.8), A4]; + let urls: Vec = (0..5).map(|i| format!("blob:https://example.test/{i}")).collect(); + let html = print_document_html(&sheets, &urls); + // The paper is the first sheet's. + assert!(html.contains("@page{size:612pt 792pt;margin:0}"), "{html}"); + assert_eq!(html.matches("\"\""), "{html}"); + assert!(html.contains(".turned img{width:792pt;height:612pt;transform-origin:0 0;transform:translate(0,792pt) rotate(-90deg)}"), "{html}"); + // A4 gets one named page, shared by both A4 sheets; a size within half a point is the paper. + assert_eq!(html.matches("@page s0{size:595.28pt 841.89pt;margin:0}").count(), 1); + assert!(!html.contains("@page s1"), "{html}"); + assert_eq!(html.matches("
").count(), 2); + assert!(html.contains("
\"\"
")); + assert!(html.contains("div:last-child{break-after:auto")); + let quoted = print_document_html(&[LETTER], &["blob:x\">