From f84e458ccfe8e5f677f5d683fd4b6f1086a60469 Mon Sep 17 00:00:00 2001 From: ffishman <20211677+ffishman@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:18:29 -0300 Subject: [PATCH 1/5] Keep a landscape media's size order and rotation when saving DXF/DWG Page setups keep a portrait size and a landscape flag, so a layout read with 44 = 420, 45 = 297, 73 = 0 and the media ISO_full_bleed_A3_(420.00_x_297.00_MM) was written back as 297 x 420 with rotation 1. ZWCAD takes the media's size from its name and turns it a quarter on top, so the sheet plotted as a shrunk portrait page (#1463). When the media's name gives it wider than tall, write 44/45 in that order and the rotation relative to it. Other media are written as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/io/src/dxf_write.rs | 34 ++++++++++++++++++++++--- crates/io/src/tests.rs | 52 ++++++++++++++++++++++++++++++++++++++ docs/file-format-parity.md | 1 + 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 34bee75b2..539f8b6ee 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -2559,13 +2559,14 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec { w.s(2, if device.is_empty() || device.eq_ignore_ascii_case("None") { "none_device" } else { device }); w.s(4, page.paper.replace(' ', "_")); w.s(6, ""); + let (paper_w, paper_h, rotation) = plot_paper(page); for (c, v) in [ (40, page.margins_mm[0]), (41, page.margins_mm[1]), (42, page.margins_mm[2]), (43, page.margins_mm[3]), - (44, page.width_mm), - (45, page.height_mm), + (44, paper_w), + (45, paper_h), (46, 0.0), (47, 0.0), (48, page.plot_window[0]), @@ -2580,7 +2581,7 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec { w.i(70, plot_flags); // Plot paper units: 1 millimetres, 0 inches (#1058). w.i(72, i64::from(cadcraft_render::paper::page_unit_mm(d, page) == 1.0)); - w.i(73, i64::from(page.landscape)); + w.i(73, rotation); w.i(74, plot_type); w.s(7, &page.plot_style_table); w.i(75, if page.scale_to_fit { 0 } else { 16 }); @@ -2639,6 +2640,33 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec { /// Write DXF Reference group 91 on every CLASS record. Count records after `dxf_raw::finish` /// restores foreign classes and objects so every class gets its actual instance count. +/// Paper size (groups 44, 45) and plot rotation (group 73) for a LAYOUT object. Page setups +/// keep a portrait size and a landscape flag. A media whose name makes it wider than tall, such +/// as `ISO_full_bleed_A3_(420.00_x_297.00_MM)`, gets its size in that order and the rotation +/// relative to it, so readers that take the media's size from its name agree with groups 44 and +/// 45 (#1463). Other media are written as before. +fn plot_paper(page: &PageSetup) -> (f64, f64, i64) { + if media_is_landscape(&page.paper) { + let (short, long) = (page.width_mm.min(page.height_mm), page.width_mm.max(page.height_mm)); + (long, short, i64::from(!page.landscape)) + } else { + (page.width_mm, page.height_mm, i64::from(page.landscape)) + } +} + +/// Whether a media name ends in a size wider than tall, like `… (420.00 x 297.00 MM)`. +fn media_is_landscape(paper: &str) -> bool { + let name = paper.replace('_', " "); + let Some(open) = name.rfind('(') else { return false }; + let mut sides = name[open + 1..].splitn(2, ['x', 'X', '×']); + let num = |s: Option<&str>| { + let s = s?.trim_start(); + let end = s.find(|c: char| !(c.is_ascii_digit() || c == '.')).unwrap_or(s.len()); + s[..end].parse::().ok() + }; + matches!((num(sides.next()), num(sides.next())), (Some(w), Some(h)) if w > h) +} + fn add_class_instance_counts(tags: &mut Vec) { let mut counts: HashMap = HashMap::new(); for t in tags.iter().filter(|t| t.code == 0) { diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 2c741b803..cae9cc749 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -560,6 +560,58 @@ fn dxf_roundtrips_page_setup() { assert_eq!(p.paper, a3.name); } +/// Groups 44, 45 and 73 of a layout's AcDbPlotSettings in DXF text. +fn plot_paper_groups(dxf: &str, layout: &str) -> (f64, f64, i64) { + let lines: Vec<&str> = dxf.lines().map(str::trim).collect(); + let pairs: Vec<(&str, &str)> = lines.chunks(2).filter(|c| c.len() == 2).map(|c| (c[0], c[1])).collect(); + let name = pairs.windows(2).position(|w| w[0] == ("100", "AcDbLayout") && w[1] == ("1", layout)).unwrap_or_else(|| panic!("no LAYOUT {layout}")); + let start = pairs[..name].iter().rposition(|p| *p == ("100", "AcDbPlotSettings")).unwrap(); + let get = |code: &str| pairs[start..name].iter().find(|p| p.0 == code).map(|p| p.1).unwrap(); + (get("44").parse().unwrap(), get("45").parse().unwrap(), get("73").parse().unwrap()) +} + +#[test] +fn landscape_media_keeps_its_size_order_and_rotation() { + // A media defined wider than tall, as ZWCAD and AutoCAD name it (#1463). + let media = "ISO full bleed A3 (420.00 x 297.00 MM)"; + for (landscape, rotation) in [(true, 0), (false, 1)] { + let mut d = sample(); + { + let p = &mut d.layouts[0].page; + p.paper = media.into(); + p.width_mm = 297.0; + p.height_mm = 420.0; + p.landscape = landscape; + } + let dxf = write_dxf(&d); + assert_eq!(plot_paper_groups(&dxf, "Layout1"), (420.0, 297.0, rotation), "DXF, landscape {landscape}"); + let r = read_dxf(dxf.as_bytes()).unwrap(); + let p = &r.layout("Layout1").unwrap().page; + assert_eq!((p.width_mm, p.height_mm, p.landscape), (297.0, 420.0, landscape), "DXF read back"); + assert_eq!(p.paper, media); + + let dwg = write(&d, "t.dwg").unwrap(); + let back = String::from_utf8_lossy(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).into_owned(); + assert_eq!(plot_paper_groups(&back, "Layout1"), (420.0, 297.0, rotation), "DWG, landscape {landscape}"); + let r = read(&dwg, "t.dwg").unwrap(); + let p = &r.layout("Layout1").unwrap().page; + assert_eq!((p.width_mm, p.height_mm, p.landscape), (297.0, 420.0, landscape), "DWG read back"); + } +} + +#[test] +fn portrait_media_is_written_as_before() { + let mut d = sample(); + { + let p = &mut d.layouts[0].page; + p.paper = "ISO A3 (297.00 x 420.00 MM)".into(); + p.width_mm = 297.0; + p.height_mm = 420.0; + p.landscape = true; + } + assert_eq!(plot_paper_groups(&write_dxf(&d), "Layout1"), (297.0, 420.0, 1)); +} + // --------------------------------------------------------------------------------------------- // Round trips of styles, overrides, associativity, constraints and tables. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c598193d7..6c92c30a7 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -138,6 +138,7 @@ our output in AutoCAD black-box. | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | DXF/DWG save: a layout whose media is named wider than tall (`ISO_full_bleed_A3_(420.00_x_297.00_MM)`) keeps its paper size in that order (44 = 420, 45 = 297) and its plot rotation relative to the media, instead of a portrait size with rotation 1 that ZWCAD plotted as a shrunk portrait page (#1463); other media are written as before | | 2026-10-17 | minor | DWG read: DGN line style components no line style definition reaches are left out before the DXF bridge, with a read note; an 83 MB site plan with 888,483 of them (1.2 GB of DXF, over the limit) now opens (#1482) | | 2026-10-12 | minor | PDF output: object transparency plotted as PDF alpha (`/ExtGState` `/ca` `/CA`) when the page setup's or the plot's "Plot transparency" is on; PNG plots blend it, SVG uses `opacity`; plots without it are unchanged byte for byte | | 2026-10-12 | minor | DXF/DWG save: the Model layout's plot settings are written as CADCraft plots model space, drawing extents scaled to fit (PLOTSETTINGS 74 = 1, 75 = 0), instead of plot type 5 "Layout information", which other programs' Plot dialogs can't use for model space | From 241aad2425938a4311733dc4b9087ed0c44cd286 Mon Sep 17 00:00:00 2001 From: ffishman <20211677+ffishman@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:35:10 -0300 Subject: [PATCH 2/5] docs: file-format parity status line for the page setup change Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/file-format-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 6c92c30a7..9d4553ecb 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (DWG read leaves out orphaned DGN line style components) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (DXF/DWG save keeps a landscape media's paper size order and plot rotation) · **Target:** Autodesk AutoCAD 2027 Every format AutoCAD 2027 reads or writes, with CADCraft's support, fidelity and tests. Work items are in [gaps.md](gaps.md#file-formats); the weighted score feeds From b52d5fbdff20771f3ac5ec2560464a8bde23fa26 Mon Sep 17 00:00:00 2001 From: ffishman <20211677+ffishman@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:09:52 -0300 Subject: [PATCH 3/5] io: put the CLASS group 91 doc comment back on its function plot_paper had been inserted between add_class_instance_counts and its doc comment (review on #2028). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/io/src/dxf_write.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 43cca500d..0e4a6e916 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -2694,8 +2694,6 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec { w.t } -/// Write DXF Reference group 91 on every CLASS record. Count records after `dxf_raw::finish` -/// restores foreign classes and objects so every class gets its actual instance count. /// Paper size (groups 44, 45) and plot rotation (group 73) for a LAYOUT object. Page setups /// keep a portrait size and a landscape flag. A media whose name makes it wider than tall, such /// as `ISO_full_bleed_A3_(420.00_x_297.00_MM)`, gets its size in that order and the rotation @@ -2723,6 +2721,8 @@ fn media_is_landscape(paper: &str) -> bool { matches!((num(sides.next()), num(sides.next())), (Some(w), Some(h)) if w > h) } +/// Write DXF Reference group 91 on every CLASS record. Count records after `dxf_raw::finish` +/// restores foreign classes and objects so every class gets its actual instance count. fn add_class_instance_counts(tags: &mut Vec) { let mut counts: HashMap = HashMap::new(); for t in tags.iter().filter(|t| t.code == 0) { From 42503a8ecd9edd09b6e33c92bef87f7d0c2fc470 Mon Sep 17 00:00:00 2001 From: ffishman <20211677+ffishman@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:46:19 -0300 Subject: [PATCH 4/5] Keep a file's own paper size and plot rotation when saving DXF/DWG A media named wider than tall is stored two ways: AutoCAD writes a portrait size and a quarter turn (ISO_A4_(297.00_x_210.00_MM) as 210 x 297, 73 = 1), ZWCAD the media's own size and no turn (420 x 297, 73 = 0). Deriving the groups from the name alone turned the AutoCAD form into the ZWCAD one. The reader now keeps groups 44, 45 and 73 as read (PageSetup::file_paper, not serialized), and the writer puts them back while they still describe the page setup's size and orientation; page setups made or changed in CADCraft are derived from the media name as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/doc/src/tables.rs | 8 +++ crates/io/src/dxf_read/objects.rs | 1 + crates/io/src/dxf_write/sections.rs | 17 ++++-- crates/io/src/tests.rs | 52 +++++++++++++++++++ .../io/tests/it/tables_objects_roundtrip.rs | 4 +- 5 files changed, 77 insertions(+), 5 deletions(-) diff --git a/crates/doc/src/tables.rs b/crates/doc/src/tables.rs index a2c63ca6a..692dd2029 100644 --- a/crates/doc/src/tables.rs +++ b/crates/doc/src/tables.rs @@ -799,6 +799,13 @@ pub struct PageSetup { /// The layout's paper units are millimetres (plot paper units 1 in DXF) whatever the /// drawing's MEASUREMENT says; otherwise paper units follow MEASUREMENT (#1058). pub paper_mm: bool, + /// Paper size (DXF groups 44, 45) and plot rotation (group 73) as the file stored them. + /// Saving writes them back while they still describe `width_mm`, `height_mm` and + /// `landscape`, so each program's convention for media defined wider than tall survives a + /// round trip: AutoCAD stores a portrait size and a quarter turn, ZWCAD the media's own size + /// and no turn (#1463). `None` for page setups made in CADCraft. + #[serde(skip)] + pub file_paper: Option<(f64, f64, i64)>, } impl Default for PageSetup { @@ -820,6 +827,7 @@ impl Default for PageSetup { show_plot_styles: false, plot_transparency: false, paper_mm: false, + file_paper: None, } } } diff --git a/crates/io/src/dxf_read/objects.rs b/crates/io/src/dxf_read/objects.rs index 4d692ccee..3c36960ff 100644 --- a/crates/io/src/dxf_read/objects.rs +++ b/crates/io/src/dxf_read/objects.rs @@ -241,6 +241,7 @@ pub(super) fn page_setup(t: &T) -> PageSetup { let rotated = matches!(t.i(73), Some(1 | 3)); if w > 0.0 && h > 0.0 { p.landscape = rotated != (w > h); + p.file_paper = Some((w, h, t.i(73).unwrap_or(0))); } else if t.i(73).is_some() { p.landscape = rotated; } diff --git a/crates/io/src/dxf_write/sections.rs b/crates/io/src/dxf_write/sections.rs index 9fed1cdcc..f8f199758 100644 --- a/crates/io/src/dxf_write/sections.rs +++ b/crates/io/src/dxf_write/sections.rs @@ -1124,11 +1124,20 @@ pub(super) fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec (f64, f64, i64) { + if let Some((w, h, rotation)) = page.file_paper { + let same = |a: f64, b: f64| (a - b).abs() < 1e-6; + let landscape = matches!(rotation, 1 | 3) != (w > h); + if same(w.min(h), page.width_mm) && same(w.max(h), page.height_mm) && landscape == page.landscape { + return (w, h, rotation); + } + } if media_is_landscape(&page.paper) { let (short, long) = (page.width_mm.min(page.height_mm), page.width_mm.max(page.height_mm)); (long, short, i64::from(!page.landscape)) diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 8bfb4980b..0d603be76 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -576,6 +576,58 @@ fn landscape_media_keeps_its_size_order_and_rotation() { } } +/// A file's own groups 44, 45 and 73 are written back unchanged, in DXF and DWG, whether they +/// follow ZWCAD's convention (the media's size, no turn) or AutoCAD's (a portrait size and a +/// quarter turn) for a media named wider than tall. Once the page setup changes, they no +/// longer describe it and the size and rotation are derived again. +#[test] +fn a_files_own_paper_groups_are_written_back() { + let set = |dxf: &str, (w, h, r): (f64, f64, i64)| -> String { + let mut lines: Vec = dxf.lines().map(String::from).collect(); + let name = (0..lines.len() - 3) + .find(|&i| { + lines[i].trim() == "100" && lines[i + 1].trim() == "AcDbLayout" && lines[i + 2].trim() == "1" && lines[i + 3].trim() == "Layout1" + }) + .unwrap(); + let start = (0..name).rev().find(|&i| lines[i].trim() == "100" && lines[i + 1].trim() == "AcDbPlotSettings").unwrap(); + for i in (start..name).step_by(2) { + match lines[i].trim() { + "44" => lines[i + 1] = w.to_string(), + "45" => lines[i + 1] = h.to_string(), + "73" => lines[i + 1] = r.to_string(), + _ => {} + } + } + lines.join("\n") + }; + // (media, groups as the program wrote them, portrait size and landscape CADCraft reads) + let cases = [ + ("ISO full bleed A3 (420.00 x 297.00 MM)", (420.0, 297.0, 0), (297.0, 420.0, true)), + ("ISO A4 (297.00 x 210.00 MM)", (210.0, 297.0, 1), (210.0, 297.0, true)), + ("ISO A4 (297.00 x 210.00 MM)", (210.0, 297.0, 0), (210.0, 297.0, false)), + ]; + for (media, groups, (pw, ph, landscape)) in cases { + let mut d = sample(); + d.layouts[0].page.paper = media.into(); + let file = set(&write_dxf(&d), groups); + let r = read_dxf(file.as_bytes()).unwrap(); + let p = &r.layout("Layout1").unwrap().page; + assert_eq!((p.width_mm, p.height_mm, p.landscape), (pw, ph, landscape), "{media} {groups:?} read"); + assert_eq!(plot_paper_groups(&write_dxf(&r), "Layout1"), groups, "{media} {groups:?} DXF"); + let dwg = write(&r, "t.dwg").unwrap(); + let back = String::from_utf8_lossy(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).into_owned(); + assert_eq!(plot_paper_groups(&back, "Layout1"), groups, "{media} {groups:?} DWG"); + let r = read(&dwg, "t.dwg").unwrap(); + assert_eq!(plot_paper_groups(&write_dxf(&r), "Layout1"), groups, "{media} {groups:?} DWG read and saved again"); + } + // Turned in CADCraft after reading: the stored groups no longer apply. + let mut d = sample(); + d.layouts[0].page.paper = "ISO full bleed A3 (420.00 x 297.00 MM)".into(); + let mut r = read_dxf(set(&write_dxf(&d), (420.0, 297.0, 0)).as_bytes()).unwrap(); + r.layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page.landscape = false; + assert_eq!(plot_paper_groups(&write_dxf(&r), "Layout1"), (420.0, 297.0, 1), "portrait after the change"); +} + #[test] fn portrait_media_is_written_as_before() { let mut d = sample(); diff --git a/crates/io/tests/it/tables_objects_roundtrip.rs b/crates/io/tests/it/tables_objects_roundtrip.rs index 0f75e4cd3..5a6ad5a4a 100644 --- a/crates/io/tests/it/tables_objects_roundtrip.rs +++ b/crates/io/tests/it/tables_objects_roundtrip.rs @@ -100,7 +100,9 @@ fn layout_page_setup_and_paper_view_round_trip() { let r = round_trip(&d); for (a, b) in d.layouts.iter().zip(&r.layouts) { assert_eq!(a.name, b.name); - assert_eq!(a.page, b.page, "{}", a.name); + // The groups 44, 45 and 73 the file stored are provenance, not a setting. + let read = cadcraft_doc::PageSetup { file_paper: None, ..b.page.clone() }; + assert_eq!(a.page, read, "{}", a.name); assert_eq!(a.view, b.view, "{}", a.name); } assert_eq!(r.layouts.len(), d.layouts.len()); From 36e8c02682c6eecafbf1d1255dcfd7b141fabb3f Mon Sep 17 00:00:00 2001 From: ffishman <20211677+ffishman@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:54:11 -0300 Subject: [PATCH 5/5] Leave the parity docs to the maintainers AGENTS.md: PRs name the affected rows and maintainers update docs/file-format-parity.md in batches. --- docs/file-format-parity.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 34318ea7a..d85415123 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -143,7 +143,6 @@ our output in AutoCAD black-box. | 2026-10-29 | minor | R2000 DXF: CLASS records written without group 91 (instance count), which came with the 2004 format; AutoCAD LT 2026 stopped responding on every R2000 DXF CADCraft saved (checked black-box in AutoCAD LT 2026) | | 2026-10-26 | minor | CC-046: ATTEDIT and -ATTEDIT value changes invalidate derived justified baselines before DXF/DWG export; preserve alignment anchors, unchanged values and Aligned/Fit endpoints. Review follow-up: baseline recomputation preserves insertion elevation independently of alignment elevation. The shared writer correction also applies to justified TEXT. Independent of attribute editor UI; reverting the follow-up restores elevation loss on recomputation, while reverting the value setter restores stale XY baselines. | | 2026-10-25 | minor | LAYOUT SAveas (`layout.saveas`) writes a layout as a drawing template: its page setup and paper space objects, no model space objects or other layouts, and only the named objects they use | -| 2026-10-23 | minor | DXF/DWG save: a layout whose media is named wider than tall (`ISO_full_bleed_A3_(420.00_x_297.00_MM)`) keeps its paper size in that order (44 = 420, 45 = 297) and its plot rotation relative to the media, instead of a portrait size with rotation 1 that ZWCAD plotted as a shrunk portrait page (#1463); other media are written as before | | 2026-10-22 | minor | DXF/DWG save: angular, diametric and radial dimensions get standard DIMASSOC objects (one centre reference to the circle for radial/diametric, the definition points for angular), so AutoCAD follows their geometry; the standard objects of those kinds are read back too, without CADCraft's xdata (#1651) | | 2026-10-19 | minor | DXF/DWG save: angular dimension block text honors DIMDSEP, including per-dimension overrides (#1646) | | 2026-10-19 | minor | A file's own arrowhead blocks with standard names (AutoCAD's `_Dot`, `_DotSmall`), named only by a dimension style, are kept and saved as they were instead of being replaced by CADCraft's solids; PURGE keeps arrowhead blocks dimension styles name |