From cd7e20993487990b189a83cf7b274c4968fc7989 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 08:10:42 -0800 Subject: [PATCH] PDF: pages longer than 200 in are written at full size in points by default PDF export without a PDF/X or PDF/A standard writes pages longer than 200 in at full size in points instead of with a UserUnit scale. longPagesInPoints is now on by default in PdfSettings, the built-in presets and the Save PDF dialog. Turning it off writes such a page with a UserUnit from PDF 1.6 on, as before. PDF/A-2b files keep the UserUnit, and .ai saves keep it unless longPagesInPoints: true is passed. Docs: params text, docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/fileio/pdf.rs | 6 +-- crates/engine/src/cmd/fileio/save.rs | 4 ++ crates/engine/src/tests/pdfpresets.rs | 53 ++++++++++-------- crates/pdf/src/settings.rs | 31 +++++++++-- crates/pdf/src/tests/marks.rs | 57 +++++++++++--------- crates/ui-egui/src/dialogs/save_pdf/tests.rs | 4 +- crates/ui-egui/src/dialogs/tests_export.rs | 14 ++++- docs/mcp.md | 8 +-- docs/target-app-parity.md | 2 +- 9 files changed, 117 insertions(+), 62 deletions(-) diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index acc22c7876..ec7d43472d 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -98,8 +98,8 @@ pub(crate) const OPTIONS: &[FormatOption] = &[ FormatOption { name: "longPagesInPoints", ty: "boolean", - default: "false", - description: "a page with a side longer than 14,400 pt (200 in) is written at its size in points, without a UserUnit, at every PDF version, in files without a standard (PDF/A files keep the UserUnit)", + default: "true", + description: "a page with a side longer than 14,400 pt (200 in) is written at its size in points, without a UserUnit, at every PDF version, in files without a standard (PDF/A files keep the UserUnit); false writes it with a UserUnit from PDF 1.6 on", }, FormatOption { name: "createLayers", @@ -128,7 +128,7 @@ pub fn specs() -> Vec { "Export PDF", [], None, - "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), useArtboards?: true (false: one page, the bounds of the visible art, as document.export writes it; artboard, artboards and range are then ignored, Preserve Editing is left out (with a warning when it is on), and a document with no visible art is refused), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: false (true: such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", + "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), useArtboards?: true (false: one page, the bounds of the visible art, as document.export writes it; artboard, artboards and range are then ignored, Preserve Editing is left out (with a warning when it is on), and a document with no visible art is refused), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; in pdfA2b files, and with longPagesInPoints: false, a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: true (such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in; false: the UserUnit as above), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", has_doc, export_pdf ), diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index e1cbf68bf2..aa0b2e9a58 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -699,6 +699,10 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res if o.get("standard").is_none_or(Value::is_null) { o.entry("createLayers").or_insert(json!(true)); } + // A page longer than 14,400 pt keeps its `UserUnit` (PDF export writes it in points). + if o.get("longPagesInPoints").is_none_or(Value::is_null) { + o.insert("longPagesInPoints".into(), json!(false)); + } if let Some(c) = o.get("compress").and_then(Value::as_bool) { let compression = o.entry("compression").or_insert_with(|| json!({})); if let Some(m) = compression.as_object_mut() { diff --git a/crates/engine/src/tests/pdfpresets.rs b/crates/engine/src/tests/pdfpresets.rs index 7de08463f3..ecdf140c0e 100644 --- a/crates/engine/src/tests/pdfpresets.rs +++ b/crates/engine/src/tests/pdfpresets.rs @@ -123,40 +123,49 @@ fn presets_export_and_import_round_trip() { assert!(is_bad_params(Session::new().execute("pdf.preset.export", &json!({}))), "nothing saved to export"); } -/// `longPagesInPoints` is saved with a preset, comes back through a presets file and the -/// preferences, shows in `document.pdfSettings`, and a 10 m page exported with it reopens at 10 m. -/// A value that isn't a boolean is refused. +/// `longPagesInPoints` is on by default: a 10 m page exported without options is written in +/// points and reopens at 10 m. Turned off, it is saved with a preset, comes back through a presets +/// file and the preferences, shows in `document.pdfSettings`, and the page exported with it has a +/// `UserUnit` and reopens at 10 m. A value that isn't a boolean is refused. #[test] fn long_pages_in_points_round_trips_through_presets_and_reopens_at_full_size() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 28_346.456_692_913_386, "height": 2_834.645_669_291_338_5})).unwrap(); - s.execute("pdf.preset.save", &json!({"name": "Banner", "longPagesInPoints": true, "preserveEditing": false})).unwrap(); - assert!(s.prefs.pdf_presets[0].settings.long_pages_in_points); + let reopened = |bytes: &[u8]| { + let mut o = Session::new(); + o.execute("document.open", &json!({"name": "banner.pdf", "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap(); + let rect = o.doc().unwrap().doc.artboards[0].rect; + assert!((rect.width() - 28_346.46).abs() < 0.01 && (rect.height() - 2_834.65).abs() < 0.01, "{rect:?}"); + }; + let warned = |v: &Value, end: &str| v["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().ends_with(end)); + // By default the page is written in points. + assert_eq!(s.execute("document.pdfSettings", &json!({})).unwrap()["settings"]["longPagesInPoints"], json!(true)); + let v = s.execute("document.exportPdf", &json!({"preserveEditing": false})).unwrap(); + let bytes = pdf(&v); + assert!(!String::from_utf8_lossy(&bytes).contains("/UserUnit")); + assert!(warned(&v, "written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in"), "{v}"); + reopened(&bytes); + // Off in a preset. + s.execute("pdf.preset.save", &json!({"name": "Banner", "longPagesInPoints": false, "preserveEditing": false})).unwrap(); + assert!(!s.prefs.pdf_presets[0].settings.long_pages_in_points); let back: crate::Prefs = serde_json::from_value(s.prefs.to_json()).unwrap(); assert_eq!(back.pdf_presets, s.prefs.pdf_presets); let text = s.execute("pdf.preset.export", &json!({"names": ["Banner"]})).unwrap()["data"].as_str().unwrap().to_string(); let mut t = session(); t.execute("pdf.preset.import", &json!({"data": text})).unwrap(); assert_eq!(t.prefs.pdf_presets, s.prefs.pdf_presets); - let v = s.execute("document.pdfSettings", &json!({"longPagesInPoints": true})).unwrap(); - assert_eq!((&v["settings"]["longPagesInPoints"], &v["changed"]), (&json!(true), &json!([{"option": "longPagesInPoints", "value": true}])), "{v}"); - assert_eq!(s.execute("document.pdfSettings", &json!({})).unwrap()["settings"]["longPagesInPoints"], json!(false)); - // The preset writes the page in points; the file reopens at 10 m. - let v = s.execute("document.exportPdf", &json!({"preset": "Banner"})).unwrap(); - let bytes = pdf(&v); - assert!(!String::from_utf8_lossy(&bytes).contains("/UserUnit")); - assert!( - v["warnings"] - .as_array() - .unwrap() - .iter() - .any(|w| w.as_str().unwrap().ends_with("written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in")), + let v = s.execute("document.pdfSettings", &json!({"longPagesInPoints": false})).unwrap(); + assert_eq!( + (&v["settings"]["longPagesInPoints"], &v["changed"]), + (&json!(false), &json!([{"option": "longPagesInPoints", "value": false}])), "{v}" ); - let mut o = Session::new(); - o.execute("document.open", &json!({"name": "banner.pdf", "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); - let rect = o.doc().unwrap().doc.artboards[0].rect; - assert!((rect.width() - 28_346.46).abs() < 0.01 && (rect.height() - 2_834.65).abs() < 0.01, "{rect:?}"); + // The preset writes the page with a `UserUnit`; the file reopens at 10 m. + let v = s.execute("document.exportPdf", &json!({"preset": "Banner"})).unwrap(); + let bytes = pdf(&v); + assert!(String::from_utf8_lossy(&bytes).contains("/UserUnit 1.969")); + assert!(warned(&v, "it is written with a UserUnit of 1.969, so apps that ignore UserUnit show it that many times smaller"), "{v}"); + reopened(&bytes); for bad in [json!("yes"), json!(1)] { assert!(is_bad_params(s.execute("document.exportPdf", &json!({"longPagesInPoints": bad}))), "{bad}"); assert!(is_bad_params(s.execute("pdf.preset.save", &json!({"name": "Bad", "longPagesInPoints": bad}))), "{bad}"); diff --git a/crates/pdf/src/settings.rs b/crates/pdf/src/settings.rs index 710037a7c1..adaaa30c9c 100644 --- a/crates/pdf/src/settings.rs +++ b/crates/pdf/src/settings.rs @@ -217,7 +217,7 @@ choice! { } /// Save PDF settings (the dialog's sections; what a preset stores). -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct PdfSettings { pub standard: Standard, @@ -237,9 +237,9 @@ pub struct PdfSettings { /// `create_layers` is on). pub include_non_printing: bool, /// Write a page with a side longer than 14,400 pt (200 in) at its size in points, without a - /// `UserUnit`, at every PDF version, in files without a standard (off, and in PDF/A files: - /// PDF 1.6 and later files that aren't PDF/X files write it with a `UserUnit`, and PDF 1.5 - /// files are written as PDF 1.6 for it). + /// `UserUnit`, at every PDF version, in files without a standard (on by default; off, and in + /// PDF/A files: PDF 1.6 and later files that aren't PDF/X files write it with a `UserUnit`, + /// and PDF 1.5 files are written as PDF 1.6 for it). pub long_pages_in_points: bool, pub compression: CompressionSettings, pub marks: MarkSettings, @@ -253,6 +253,29 @@ pub struct PdfSettings { pub flattener_preset: String, } +impl Default for PdfSettings { + fn default() -> Self { + Self { + standard: Standard::default(), + compatibility: Compatibility::default(), + preserve_editing: false, + thumbnails: false, + fast_web_view: false, + view_after_saving: false, + create_layers: false, + include_non_printing: false, + long_pages_in_points: true, + compression: CompressionSettings::default(), + marks: MarkSettings::default(), + bleed: BleedSettings::default(), + output: OutputSettings::default(), + advanced: AdvancedSettings::default(), + security: SecuritySettings::default(), + flattener_preset: String::new(), + } + } +} + /// Resampling and compression of one kind of image. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] diff --git a/crates/pdf/src/tests/marks.rs b/crates/pdf/src/tests/marks.rs index 655ed43ea4..4693b4c44b 100644 --- a/crates/pdf/src/tests/marks.rs +++ b/crates/pdf/src/tests/marks.rs @@ -105,8 +105,9 @@ fn units_and_media(bytes: &[u8]) -> Vec<(f32, Rect)> { } /// A page with a side longer than ISO 32000-1's 14,400 units is written in larger units (its -/// `UserUnit`), so that its sides are 14,400 units at most, and reads back at its size in points. -/// Pages up to 14,400 pt, files before PDF 1.6 and PDF/X files keep their sizes in points. +/// `UserUnit`), so that its sides are 14,400 units at most, and reads back at its size in points, +/// with `longPagesInPoints` off. Pages up to 14,400 pt, files before PDF 1.6 and PDF/X files keep +/// their sizes in points. #[test] fn a_page_past_14400_pt_is_written_with_a_user_unit_and_reads_back_at_its_size() { let mut d = doc(); @@ -117,7 +118,7 @@ fn a_page_past_14400_pt_is_written_with_a_user_unit_and_reads_back_at_its_size() small.rect = Rect::new(0.0, 12_000.0, 20_000.0, 12_001.0); d.artboards.push(small); let near = |a: Rect, b: Rect| [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 0.01); - let bytes = pdf(&d, json!({})); + let bytes = pdf(&d, json!({"longPagesInPoints": false})); let pages = units_and_media(&bytes); // 20,000 / 14,400 = 1.3889, rounded up to 1.389: the page is 14,398.85 × 7,199.42 units. assert_eq!(pages[0].0, 1.389, "{pages:?}"); @@ -155,6 +156,12 @@ fn report(d: &Document, set: PdfSettings) -> Result { export_with_report(d, &PdfOptions { settings: set, created: Some(1_791_200_000), ..PdfOptions::uncompressed() }) } +/// The default settings with `long_pages_in_points` off: long pages from PDF 1.6 on get a +/// `UserUnit`. +fn user_units() -> PdfSettings { + PdfSettings { long_pages_in_points: false, ..PdfSettings::default() } +} + /// The numbers that follow `key` in the file's bytes (`/MediaBox [0 0 w h]`, `/UserUnit u`), read /// as text, without the PDF reader: none when the file has no `key`. fn raw_numbers(bytes: &[u8], key: &str, count: usize) -> Vec { @@ -178,13 +185,13 @@ fn long_page_warnings(r: &ExportReport) -> Vec<&String> { r.warnings.iter().filter(|w| w.contains("page limit of 14,400 pt")).collect() } -/// A 20 m page at PDF 1.7 (the default) is written with a `UserUnit` of 3.938, reads back at 20 m -/// from the file's bytes, through the PDF reader and through the import, and the export says how -/// it is written. +/// A 20 m page at PDF 1.7 (the default) with `long_pages_in_points` off is written with a +/// `UserUnit` of 3.938, reads back at 20 m from the file's bytes, through the PDF reader and +/// through the import, and the export says how it is written. #[test] fn a_20_m_page_keeps_its_size_with_a_user_unit_and_the_export_says_so() { let d = sized(TWENTY_M, TWENTY_M / 20.0); - let r = report(&d, PdfSettings::default()).unwrap(); + let r = report(&d, user_units()).unwrap(); assert!(r.bytes.starts_with(b"%PDF-1.7")); assert_eq!( long_page_warnings(&r), @@ -209,7 +216,7 @@ fn a_20_m_page_keeps_its_size_with_a_user_unit_and_the_export_says_so() { /// Every built-in preset and every PDF version keeps a 20 m page at its size, or refuses it with /// the reason: PDF 1.3 and 1.4 files (PDF/X-1a and PDF/X-3 among them) have no `UserUnit` and /// hold no real number past 32,767, PDF 1.5 is written as PDF 1.6 to have one, and PDF/X-4 pages -/// at PDF 1.5 or 1.6 stay in points with a warning. +/// at PDF 1.5 or 1.6 stay in points with a warning (with `long_pages_in_points` off). #[test] fn every_preset_and_version_keeps_a_20_m_page_or_says_why_not() { let d = sized(TWENTY_M, TWENTY_M / 20.0); @@ -223,7 +230,7 @@ fn every_preset_and_version_keeps_a_20_m_page_or_says_why_not() { }; for preset in crate::builtin_presets() { let name = preset.name.as_str(); - match report(&d, preset.settings.clone()) { + match report(&d, PdfSettings { long_pages_in_points: false, ..preset.settings.clone() }) { Err(e) if matches!(name, "PDF/X-1a:2001" | "PDF/X-3:2002") => refused(e, name), Err(e) => panic!("{name}: {e}"), Ok(r) if name == "PDF/X-4:2010" => { @@ -241,7 +248,7 @@ fn every_preset_and_version_keeps_a_20_m_page_or_says_why_not() { } } for c in Compatibility::ALL.iter().copied() { - let set = PdfSettings { compatibility: c, ..PdfSettings::default() }; + let set = PdfSettings { compatibility: c, ..user_units() }; match (c, report(&d, set)) { (Compatibility::Pdf13 | Compatibility::Pdf14, Err(e)) => refused(e, c.label()), (Compatibility::Pdf13 | Compatibility::Pdf14, Ok(_)) => panic!("{} is refused", c.label()), @@ -362,7 +369,7 @@ fn the_error_names_the_long_page_whose_art_needs_a_larger_real() { #[test] fn a_page_too_narrow_for_its_user_unit_keeps_3_units_on_its_short_side() { const MM: f64 = 72.0 / 25.4; - for set in [PdfSettings::default(), PdfSettings { standard: Standard::PdfA2b, ..PdfSettings::default() }] { + for set in [user_units(), PdfSettings { standard: Standard::PdfA2b, ..PdfSettings::default() }] { let r = report(&sized(TWENTY_M * 5.0, 4.0 * MM), set).unwrap(); assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), [3.779]); let media = raw_numbers(&r.bytes, "/MediaBox", 4); @@ -377,11 +384,11 @@ fn a_page_too_narrow_for_its_user_unit_keeps_3_units_on_its_short_side() { assert!(long_page_warnings(&r).is_empty(), "{:?}", r.warnings); } // A short side of 3 units or more at the long side's unit keeps that unit. - let r = report(&sized(TWENTY_M * 5.0, 60.0), PdfSettings::default()).unwrap(); + let r = report(&sized(TWENTY_M * 5.0, 60.0), user_units()).unwrap(); assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), [19.686]); let media = raw_numbers(&r.bytes, "/MediaBox", 4); assert!(media[3] - media[1] >= 3.0, "{media:?}"); - let r = report(&sized(TWENTY_M * 5.0, 2.0), PdfSettings::default()).unwrap(); + let r = report(&sized(TWENTY_M * 5.0, 2.0), user_units()).unwrap(); assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), [19.686]); } @@ -389,7 +396,7 @@ fn a_page_too_narrow_for_its_user_unit_keeps_3_units_on_its_short_side() { /// the Save PDF dialog shows it. #[test] fn a_pdf_1_5_file_written_as_1_6_keeps_rc4_encryption() { - let mut set = PdfSettings { compatibility: Compatibility::Pdf15, ..PdfSettings::default() }; + let mut set = PdfSettings { compatibility: Compatibility::Pdf15, ..user_units() }; set.security.open_password = "secret".into(); let has = |bytes: &[u8], s: &[u8]| bytes.windows(s.len()).any(|w| w == s); let r = report(&sized(TWENTY_M, 300.0), set).unwrap(); @@ -408,7 +415,7 @@ fn the_long_page_warning_rounds_the_size_up() { /// point longer gets a `UserUnit` of 1.001 and the warning, and stays in points at PDF 1.4. #[test] fn a_page_of_14400_pt_stays_in_points_and_one_point_more_does_not() { - let at = |side: f64, c: Compatibility| report(&sized(side, 300.0), PdfSettings { compatibility: c, ..PdfSettings::default() }); + let at = |side: f64, c: Compatibility| report(&sized(side, 300.0), PdfSettings { compatibility: c, ..user_units() }); for c in [Compatibility::Pdf14, Compatibility::Pdf15, Compatibility::Pdf17] { let r = at(14_400.0, c).unwrap(); assert!(long_page_warnings(&r).is_empty() && !r.warnings.iter().any(|w| w.contains("UserUnit")), "{}: {:?}", c.label(), r.warnings); @@ -455,13 +462,14 @@ fn a_page_within_14400_pt_is_written_as_before_at_every_version() { /// 10 m in points. const TEN_M: f64 = 10.0 / 0.0254 * 72.0; -/// With `long_pages_in_points`, a 10 m page at PDF 1.7 is written at its size in points, with no -/// `UserUnit`, the warning says so, and the page reads back at 10 m. Without it the page keeps -/// its `UserUnit`. +/// With `long_pages_in_points` (on by default), a 10 m page at PDF 1.7 is written at its size in +/// points, with no `UserUnit`, the warning says so, and the page reads back at 10 m. Without it the +/// page keeps its `UserUnit`. #[test] fn long_pages_in_points_writes_a_10_m_page_in_points() { let d = sized(TEN_M, TEN_M / 10.0); - let r = report(&d, PdfSettings { long_pages_in_points: true, ..PdfSettings::default() }).unwrap(); + assert!(settings(json!({})).long_pages_in_points, "on by default"); + let r = report(&d, PdfSettings::default()).unwrap(); assert!(r.bytes.starts_with(b"%PDF-1.7")); assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), Vec::::new()); let media = raw_numbers(&r.bytes, "/MediaBox", 4); @@ -479,13 +487,12 @@ fn long_pages_in_points_writes_a_10_m_page_in_points() { let back = import(&r.bytes).unwrap(); let rect = back.artboards[0].rect; assert!((rect.width() - TEN_M).abs() < 0.01 && (rect.height() - TEN_M / 10.0).abs() < 0.01, "{rect:?}"); - // Off (the default), the page has a `UserUnit` of 28,346.46 / 14,400, rounded up. - let off = report(&d, PdfSettings::default()).unwrap(); + // Off, the page has a `UserUnit` of 28,346.46 / 14,400, rounded up. + let off = report(&d, user_units()).unwrap(); assert_eq!(raw_numbers(&off.bytes, "/UserUnit", 1), [1.969]); assert!( long_page_warnings(&off)[0].ends_with("it is written with a UserUnit of 1.969, so apps that ignore UserUnit show it that many times smaller") ); - assert!(!settings(json!({})).long_pages_in_points, "off by default"); } /// With `long_pages_in_points`, every PDF version writes a 10 m page in points: PDF 1.5 stays @@ -496,7 +503,7 @@ fn long_pages_in_points_keeps_every_version_and_changes_nothing_where_pages_are_ let d = sized(TEN_M, TEN_M / 10.0); let with = |set: &PdfSettings| PdfSettings { long_pages_in_points: true, ..set.clone() }; for c in Compatibility::ALL.iter().copied() { - let set = PdfSettings { compatibility: c, ..PdfSettings::default() }; + let set = PdfSettings { compatibility: c, ..user_units() }; let r = report(&d, with(&set)).unwrap(); assert!(r.bytes.starts_with(format!("%PDF-{}", c.id()).as_bytes()), "{}", c.label()); assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), Vec::::new(), "{}", c.label()); @@ -520,7 +527,7 @@ fn pdfa2b_long_page_stays_within_14400_units() { let d = sized(TEN_M, TEN_M / 10.0); for s in Standard::ALL.iter().copied().filter(|s| *s != Standard::None && !s.is_pdfx()) { for c in Compatibility::ALL.iter().copied().filter(|c| s.allows(*c)) { - let off = PdfSettings { standard: s, compatibility: c, ..PdfSettings::default() }; + let off = PdfSettings { standard: s, compatibility: c, ..user_units() }; let on = PdfSettings { long_pages_in_points: true, ..off.clone() }; let (on, off) = (report(&d, on).unwrap(), report(&d, off).unwrap()); assert_eq!(on.bytes, off.bytes, "{} {}", s.label(), c.label()); @@ -540,7 +547,7 @@ fn pdfa2b_long_page_stays_within_14400_units() { fn long_pages_in_points_leaves_a_page_within_14400_pt_as_it_is() { let d = sized(14_400.0, 300.0); for c in Compatibility::ALL.iter().copied() { - let off = PdfSettings { compatibility: c, ..PdfSettings::default() }; + let off = PdfSettings { compatibility: c, ..user_units() }; let on = PdfSettings { long_pages_in_points: true, ..off.clone() }; let (off, on) = (report(&d, off).unwrap(), report(&d, on).unwrap()); assert_eq!(on.bytes, off.bytes, "{}", c.label()); diff --git a/crates/ui-egui/src/dialogs/save_pdf/tests.rs b/crates/ui-egui/src/dialogs/save_pdf/tests.rs index a3a6cc4768..fda9e78cc5 100644 --- a/crates/ui-egui/src/dialogs/save_pdf/tests.rs +++ b/crates/ui-egui/src/dialogs/save_pdf/tests.rs @@ -251,14 +251,14 @@ fn long_page_notes_are_translated() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 56_692.91, "height": 2_834.65})).unwrap(); let mut notes: Vec = vec![]; - for p in [json!({"compatibility": "1.5"}), json!({"preset": "PDF/X-4:2010"})] { + for p in [json!({"compatibility": "1.5", "longPagesInPoints": false}), json!({"preset": "PDF/X-4:2010"})] { let r = s.execute("document.exportPdf", &p).unwrap(); let warnings = r["warnings"].as_array().unwrap().iter().filter_map(|w| w.as_str()); notes.extend(warnings.filter(|w| w.contains("14,400 pt")).map(str::to_string)); } notes.push(s.execute("document.exportPdf", &json!({"preset": "PDF/X-1a:2001"})).unwrap_err().to_string()); s.execute("file.new", &json!({"width": 85_040, "height": 17})).unwrap(); - let r = s.execute("document.exportPdf", &json!({})).unwrap(); + let r = s.execute("document.exportPdf", &json!({"longPagesInPoints": false})).unwrap(); let warnings = r["warnings"].as_array().unwrap().iter().filter_map(|w| w.as_str()); notes.extend(warnings.filter(|w| w.contains("too long and narrow")).map(str::to_string)); assert_eq!(notes.len(), 5, "{notes:?}"); diff --git a/crates/ui-egui/src/dialogs/tests_export.rs b/crates/ui-egui/src/dialogs/tests_export.rs index 117e83277c..ca37940601 100644 --- a/crates/ui-egui/src/dialogs/tests_export.rs +++ b/crates/ui-egui/src/dialogs/tests_export.rs @@ -205,7 +205,8 @@ fn svg_continues_in_svg_options() { /// Without Use Artboards, a PDF continues in Save PDF too, without its artboard choice, and the /// file is one page of the art's bounds. Its options apply: a drawing 10 m long is written at -/// full size in points with "Write pages longer than 200 in at full size in points". +/// full size in points with "Write pages longer than 200 in at full size in points" (on by +/// default), and with a `UserUnit` without it. #[test] fn pdf_art_continues_in_the_save_pdf_dialog() { let (mut app, written) = app(2); @@ -223,13 +224,22 @@ fn pdf_art_continues_in_the_save_pdf_dialog() { app.run("shape.rectangle", json!({"x": 0, "y": 100, "width": 28_346.46, "height": 50})).unwrap(); app.run("file.exportAs", json!({"format": "pdf"})).unwrap(); confirm(&mut app).unwrap(); - set(&mut app, "longPagesInPoints", json!(true)); frame(&mut app); confirm(&mut app).unwrap(); let bytes = written.borrow()[1].1.clone(); assert!(!String::from_utf8_lossy(&bytes).contains("/UserUnit")); let width = vectorcraft_pdf::import(&bytes).unwrap().artboards[0].rect.width(); assert!((28_346.0..28_350.0).contains(&width), "{width}"); + + app.run("file.exportAs", json!({"format": "pdf"})).unwrap(); + confirm(&mut app).unwrap(); + set(&mut app, "longPagesInPoints", json!(false)); + frame(&mut app); + confirm(&mut app).unwrap(); + let bytes = written.borrow()[2].1.clone(); + assert!(String::from_utf8_lossy(&bytes).contains("/UserUnit")); + let width = vectorcraft_pdf::import(&bytes).unwrap().artboards[0].rect.width(); + assert!((28_346.0..28_350.0).contains(&width), "{width}"); } #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 274db05a34..4701eb87c7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1036,7 +1036,8 @@ and `flattener` options over it (as `object.flattenTransparency` takes them); im transparency and rasterized areas are clipped to their regions. Layers the file leaves out (non-printing ones, unless `includeNonPrinting` or `createLayers` is set) are left out of the flattening. A file that would still have transparency fails the export. PDF/X-1a and PDF/X-3 files are PDF 1.3 files too. -A page with a side longer than 14,400 pt is written with a `UserUnit` (PDF 1.6): its longer side divided by 14,400, +In PDF/A-2b files, `.ai` saves and files written with `longPagesInPoints: false`, a page with a side longer than +14,400 pt is written with a `UserUnit` (PDF 1.6): its longer side divided by 14,400, rounded up to the next thousandth, with the page's sizes and coordinates in those units, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2). A PDF 1.5 file with such a page is written as PDF 1.6, with a warning, and keeps PDF 1.5's encryption (128-bit RC4). A PDF/A-2b file at PDF 1.4 with such a page is written as PDF 1.6 too, with a @@ -1045,9 +1046,10 @@ unless the page is too narrow for that (below) ([veraPDF rule 6.1.13-11](https://github.com/veraPDF/veraPDF-validation-profiles/wiki/PDFA-Parts-2-and-3-rules#rule-6113-11); PDF/A-2 files may declare PDF 1.0 to 1.7, [rule 6.1.2-1](https://github.com/veraPDF/veraPDF-validation-profiles/wiki/PDFA-Parts-2-and-3-rules#rule-612-1)). PDF/X files and other PDF 1.3 and 1.4 files write such a page in points, and apps -that keep to the limit cut it to 200 in. `longPagesInPoints: true` (off by default; "Write pages longer than 200 in at +that keep to the limit cut it to 200 in. `longPagesInPoints` (on by default; "Write pages longer than 200 in at full size in points" in the Save PDF dialog's General section) writes such a page in points at every PDF version, without -a `UserUnit`, and a PDF 1.5 file stays PDF 1.5; the warning then says the page is written in points. The option applies +a `UserUnit`, and a PDF 1.5 file stays PDF 1.5; the warning then says the page is written in points. Readers that +ignore `UserUnit` show such a page at its full size. The option applies only to files without a standard: PDF/X files write such a page in points anyway, and PDF/A files keep the `UserUnit`, which keeps their page boundaries within PDF/A-2's limit of 14,400 units (ISO 19005-2, 6.1.13), a PDF/A file at PDF 1.4 included (written as PDF 1.6). The dialog disables the diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f04ee3c910..94c63e2e85 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1573,7 +1573,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur In a `.ai` opened from its editing data, an envelope made with a warp, a mesh or a top object opens as a live envelope of its contents, with its warp style, bend and distortions, its mesh points and handles or its top object's outline, its Envelope Options, its Fidelity and whether its contents are being edited. When Distort Appearance is on and the size the file saves for the contents matches their bounds with strokes, the contents map from that stroked box, until an edit in Edit Contents changes how far the strokes reach; VectorCraft's new envelopes map from the geometric box, as do contents with no width or no height, such as a single straight line. An envelope that can't be read, or that VectorCraft draws differently from the file's drawn art, keeps that drawn art, with a note. An envelope of type opens live, its type laid out from the text document as its contents, where VectorCraft's own envelope of that type draws as the drawn art the file saves (compared as for the other envelopes, seen on white and on black, so white type counts, and only where the layers with it are compared with the page and draw as it, the envelope weighed as the art it draws); elsewhere, and where the type's font isn't available, a text or visibility variable binds the type, or the envelope is in a symbol's or a pattern's art, it keeps its drawn art, with its type hidden and a note that says the type isn't editable (#3179; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). A warp of type whose own warp, VectorCraft's, doesn't draw as its drawn art is tried again as a mesh envelope on the grid the file saves for it: its type stays editable and keeps the file's shape, but the warp's settings (style, bend) are lost, as the document has no place for them on a mesh. Type with strokes in an envelope with Distort Appearance on is also tried mapped from its glyphs grown by as far as its strokes paint along a side (half the weight of a centred stroke), without what miter joins add at corners, where the size the file saves for it doesn't already give that box; after those tries (and before a grid moved onto its drawn art), it is also tried mapped from the box of what it paints, where that is another box, as users' files map some such type: its glyph outlines with their fills and strokes baked as the envelope bakes them, so geometry effects on them count (a stroke a Transform effect moves paints where it is moved to), each measured by its visual bounds (exact for miter joins, a tip counting where the miter limit keeps it, and for round joins; an upper bound for bevel joins, miters the limit cuts, width profiles and square-capped dashes), and a contour of zero length, which paints nothing, left out; effects on the type object itself are left out, as VectorCraft applies them after the envelope bends the type. The drawn-art check decides which one opens. An envelope's grid reads its way round from all its rows and columns, so a mesh bent far round reads the way it runs. While an envelope's contents are edited, its mesh is kept in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art (as if the mesh hadn't followed the envelope when it moved): a mesh or top object saved in Edit Contents whose mesh draws the contents as large as the drawn art but elsewhere is tried again, after every other way, with its mesh moved onto that art, and opens there where it then draws as it. The drawn art of these files strokes type in an envelope along its bent outlines at the stroke's own weight, Distort Appearance on or off, and VectorCraft draws it so (#6644): an envelope of stroked type whose grid squeezes or stretches it opens live where it draws as that art. In a `.ai` opened from its editing data, Convert to Shape on a whole path or type object opens as a live Rectangle, Rounded Rectangle or Ellipse effect with its Absolute or Relative size, its corner radius, its place among the object's effects and whether it is hidden. A relative size in the file is added to each side. A relative Ellipse, or a setting VectorCraft doesn't read, keeps the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, Bas Relief in an art style takes its Detail, Smoothness and Light direction from the style's settings block, and an object other than type with it opens as itself with the live effect. A settings block that VectorCraft can't read (malformed, another effect's, a value out of range or a key it doesn't know) keeps the object's drawn look (part of #637; [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646); `crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`). - PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); a PDF 1.5 file with such a page is written as PDF 1.6 with PDF 1.5's encryption, PDF/X files and PDF 1.3 and 1.4 files write it in points (in PDF 1.3 and 1.4, a page past 32,767 pt that needs a real number past 32,767 is refused with an error naming it), a page whose short side is 3 pt or longer but would be under 3 units at that `UserUnit` gets a smaller one that keeps the short side at 3 units, and the export's warnings give each such page's size and how it is written (#6382). The Save PDF dialog's "Write pages longer than 200 in at full size in points" (`longPagesInPoints`, off by default) writes such pages in points without a `UserUnit` at every PDF version, in files without a PDF/X or PDF/A standard. + PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); a PDF 1.5 file with such a page is written as PDF 1.6 with PDF 1.5's encryption, PDF/X files and PDF 1.3 and 1.4 files write it in points (in PDF 1.3 and 1.4, a page past 32,767 pt that needs a real number past 32,767 is refused with an error naming it), a page whose short side is 3 pt or longer but would be under 3 units at that `UserUnit` gets a smaller one that keeps the short side at 3 units, and the export's warnings give each such page's size and how it is written (#6382). The Save PDF dialog's "Write pages longer than 200 in at full size in points" (`longPagesInPoints`, on by default; `.ai` saves leave it off) writes such pages in points without a `UserUnit` at every PDF version, in files without a PDF/X or PDF/A standard, so readers that ignore `UserUnit` show them at full size. Export As PDF with Use Artboards off also opens the Save PDF dialog, without its Artboards section, and writes one page of the visible art's bounds with the dialog's options (`document.exportPdf {useArtboards: false}`; `crates/ui-egui/src/dialogs/tests_export.rs`, `crates/engine/src/cmd/fileio/tests_pdf.rs`). PDF export warns when a PDF/X file has no bleed on a side where the document's bleed has one, and writes the file as asked. A PDF/A-2b export at PDF 1.4 with a page longer than 14,400 pt is written as PDF 1.6 with a `UserUnit` and a warning, whether or not `longPagesInPoints` is on, so its page boundaries stay within PDF/A-2's limit of 14,400 units (ISO 19005-2, 6.1.13) unless the page is too narrow for that (`crates/pdf/src/tests/marks.rs`).