diff --git a/ROADMAP.md b/ROADMAP.md index 74a617f3b..662c2e3da 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -132,6 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-28** · M1, #1476: page ruler guides survive save and reopen. A page kept its guides in the page kind while the layer's own `guides` field (frame guides, #70) took that key when a file was read, so a reopened page's guides vanished, and saving it wrote `guides` twice, which then refused to open. Every layer now keeps its guides in the one field, and a page with two `guides` lists opens with both. Regressions in the doc and format crates. Numbers unchanged. - **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged. - **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. - **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change. @@ -348,6 +349,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-28 | minor | M1, #1476: page ruler guides survive save and reopen; files written with a page's `guides` key twice open again with both lists. Numbers unchanged. | | 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged | | 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. | | 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged | diff --git a/crates/doc/src/client_id_tests.rs b/crates/doc/src/client_id_tests.rs index 520895fb7..94b676c22 100644 --- a/crates/doc/src/client_id_tests.rs +++ b/crates/doc/src/client_id_tests.rs @@ -104,13 +104,15 @@ fn place_keeps_the_local_transform_and_refuses_bad_moves() { assert!(d.validate().is_empty()); } +/// A page-less document takes pages inserted with their own ids, refuses a duplicate or a layer +/// that isn't a page, and repairs into a file with a page. #[test] fn pages_can_be_inserted_onto_a_page_less_base() { let mut d = Document::without_pages("snapshot"); assert!(d.pages.is_empty() && d.node_count() == 0); let p = Node::new( NodeId(C3 | 1), - NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: vec![], prototype: Default::default(), guides: vec![] }, + NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: vec![], prototype: Default::default() }, "P", 0.0, 0.0, diff --git a/crates/doc/src/guides_tests.rs b/crates/doc/src/guides_tests.rs new file mode 100644 index 000000000..82926e5de --- /dev/null +++ b/crates/doc/src/guides_tests.rs @@ -0,0 +1,61 @@ +//! Ruler guides survive a save and reopen (#1476). + +use crate::{Document, Guide, Node, NodeKind}; + +/// A guide: vertical (at an x offset) or horizontal (at a y offset). +fn guide(vertical: bool, offset: f64) -> Guide { + Guide { vertical, offset } +} + +/// A page's guides are written once and read back onto the page. +#[test] +fn page_guides_round_trip_through_json() { + let mut d = Document::new("a"); + let page = d.first_page().unwrap(); + d.node_mut(page).unwrap().guides_mut().push(guide(true, 120.0)); + let json = serde_json::to_string(d.node(page).unwrap()).unwrap(); + assert_eq!(json.matches("\"guides\"").count(), 1, "{json}"); + let back: Node = serde_json::from_str(&json).unwrap(); + assert_eq!(back.guides(), &[guide(true, 120.0)]); + assert_eq!(back, *d.node(page).unwrap()); +} + +/// A frame's guides round trip too, and a layer without guides writes no `guides` key. +#[test] +fn frame_guides_round_trip_and_none_are_left_out() { + let mut d = Document::new("a"); + let page = d.first_page().unwrap(); + let frame = d.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); + for id in [page, frame] { + let json = serde_json::to_value(d.node(id).unwrap()).unwrap(); + assert!(json.get("guides").is_none(), "{json}"); + } + d.node_mut(frame).unwrap().guides_mut().push(guide(false, 40.0)); + let json = serde_json::to_string(d.node(frame).unwrap()).unwrap(); + assert_eq!(json.matches("\"guides\"").count(), 1, "{json}"); + let back: Node = serde_json::from_str(&json).unwrap(); + assert_eq!(back, *d.node(frame).unwrap()); +} + +/// A page saved with two `guides` keys reads both lists, in file order, and writes them once. +#[test] +fn a_page_written_with_two_guides_lists_opens_with_both() { + // How a page was saved after its file had been reopened: the page kind's list (guides added + // since), then the layer's (the ones the file had). + let json = r#"{"id": 1, "name": "Page 1", "type": "page", "guides": [{"vertical": true, "offset": 5.0}], + "width": 0.0, "height": 0.0, "guides": [{"vertical": false, "offset": 7.0}]}"#; + let n: Node = serde_json::from_str(json).unwrap(); + assert!(matches!(n.kind, NodeKind::Page { .. })); + assert_eq!(n.guides(), &[guide(true, 5.0), guide(false, 7.0)]); + let again: Node = serde_json::from_str(&serde_json::to_string(&n).unwrap()).unwrap(); + assert_eq!(again.guides(), n.guides()); +} + +/// A `guides` value that isn't a list of guides is still refused. +#[test] +fn a_bad_guides_list_is_still_an_error() { + for bad in [r#"3"#, r#"[{"vertical": "no"}]"#, r#"{"offset": 1}"#] { + let json = format!(r#"{{"id": 1, "name": "F", "type": "frame", "width": 1.0, "height": 1.0, "guides": {bad}}}"#); + assert!(serde_json::from_str::(&json).is_err(), "{bad}"); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 7d1f69280..40a9f2da3 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -407,13 +407,8 @@ impl Document { /// Add a page at the end. pub fn add_page(&mut self, name: &str) -> NodeId { let id = self.alloc_id(); - let node = Node::new( - id, - NodeKind::Page { background: Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default(), guides: Vec::new() }, - name, - 0.0, - 0.0, - ); + let node = + Node::new(id, NodeKind::Page { background: Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default() }, name, 0.0, 0.0); self.nodes.insert(id, Arc::new(node)); self.dirty.touch(id); self.pages.push(id); @@ -1260,6 +1255,8 @@ mod diff_tests; #[cfg(test)] mod effects_tests; #[cfg(test)] +mod guides_tests; +#[cfg(test)] mod handoff_tests; #[cfg(test)] mod ids_tests; diff --git a/crates/doc/src/library.rs b/crates/doc/src/library.rs index 35994bb00..fe53ec29f 100644 --- a/crates/doc/src/library.rs +++ b/crates/doc/src/library.rs @@ -260,7 +260,7 @@ impl Document { let id = self.alloc_id(); let node = Node::new( id, - NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default(), guides: Vec::new() }, + NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default() }, STORE_NAME, 0.0, 0.0, diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 3146047a2..bcf8f09d4 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -66,6 +66,9 @@ pub enum PropDef { } /// What a node is, with per-type data. +// Text sets the size; the lint counts the gap to the next largest variant, which grew only because +// Page's guides moved to the node (#1476). +#[allow(clippy::large_enum_variant)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum NodeKind { @@ -76,8 +79,6 @@ pub enum NodeKind { flows: Vec, #[serde(default)] prototype: crate::proto::PrototypeSettings, - #[serde(default)] - guides: Vec, }, Frame, Group, @@ -472,8 +473,8 @@ pub struct Node { pub mask_type: MaskType, #[serde(default, skip_serializing_if = "is_default")] pub grids: Vec, - /// A frame's ruler guides, in its own coordinates (a page's are in [`NodeKind::Page`]). - #[serde(default, skip_serializing_if = "is_default")] + /// A page's or frame's ruler guides, in its own coordinates (see [`guides_key`]). + #[serde(flatten, with = "guides_key")] pub guides: Vec, #[serde(default, skip_serializing_if = "is_default")] pub exports: Vec, @@ -578,6 +579,77 @@ fn is_identity(v: &Affine) -> bool { *v == identity() } +/// A layer's `guides` key: left out when there are none, and every `guides` list the layer has is +/// read. Pages used to keep their guides in [`NodeKind::Page`] while the layer field took the +/// `guides` key on reading, so a reopened page lost its guides, and saving it then wrote two +/// `guides` keys, which a plain field refuses (#1476). Both lists are kept, in file order. +mod guides_key { + use super::Guide; + use serde::de::{IgnoredAny, MapAccess, Visitor}; + use serde::ser::SerializeMap; + use serde::{Deserialize, Deserializer, Serializer}; + use std::fmt; + + const KEY: &str = "guides"; + + /// Write `guides` as the layer's `guides` key, or nothing when there are none. + pub fn serialize(guides: &[Guide], s: S) -> Result { + let mut map = s.serialize_map(None)?; + if !guides.is_empty() { + map.serialize_entry(KEY, guides)?; + } + map.end() + } + + /// Read every `guides` list among the layer's fields, joined in file order. + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + d.deserialize_map(Lists) + } + + /// Reads the `guides` lists of a layer's fields and skips the other fields. + struct Lists; + + impl<'de> Visitor<'de> for Lists { + type Value = Vec; + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a layer's fields") + } + /// Every `guides` value joined, in order; a malformed one is an error. + fn visit_map>(self, mut map: A) -> Result, A::Error> { + let mut guides = Vec::new(); + while let Some(key) = map.next_key::()? { + if key.0 { + guides.extend(map.next_value::>()?); + } else { + map.next_value::()?; + } + } + Ok(guides) + } + } + + /// Whether a field name is `guides` (without copying the name). + struct Key(bool); + + impl<'de> Deserialize<'de> for Key { + /// Compare a field name with `guides` as it is read. + fn deserialize>(d: D) -> Result { + struct Name; + impl Visitor<'_> for Name { + type Value = Key; + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a field name") + } + /// `guides` or another field. + fn visit_str(self, s: &str) -> Result { + Ok(Key(s == KEY)) + } + } + d.deserialize_identifier(Name) + } + } +} + impl Node { /// A node with Figma's defaults for its kind. pub fn new(id: NodeId, kind: NodeKind, name: impl Into, width: f64, height: f64) -> Self { @@ -712,17 +784,12 @@ impl Node { /// The ruler guides on this page or frame, in its own coordinates. pub fn guides(&self) -> &[Guide] { - match &self.kind { - NodeKind::Page { guides, .. } => guides, - _ => &self.guides, - } + &self.guides } + /// The ruler guides on this page or frame, to edit. pub fn guides_mut(&mut self) -> &mut Vec { - match &mut self.kind { - NodeKind::Page { guides, .. } => guides, - _ => &mut self.guides, - } + &mut self.guides } /// Children in paint order, bottom to top: the child list, reversed in an auto layout frame diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index 44c7b3677..fefd8c73f 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -33,6 +33,7 @@ fn page_target(s: &Session, cmd: &str, p: &Value) -> crate::Result { Ok(target) } +/// The page and ruler guide commands. pub fn specs() -> Vec { vec![ spec!("page.add", "Add page", "{name?} (a name starting with a hyphen adds a divider)", true, |s, p| { @@ -106,8 +107,10 @@ pub fn specs() -> Vec { copies.extend(doc.subtree(*c).into_iter().zip(doc.subtree(*copy))); } // The copy keeps the page's colour, flows (pointing at the copied frames), prototype settings and guides. - if let NodeKind::Page { background, flows, prototype, guides } = doc.get(src)?.kind.clone() - && let NodeKind::Page { background: b, flows: f, prototype: pr, guides: g } = &mut doc.get_mut(np)?.kind + let guides = doc.get(src)?.guides.clone(); + doc.get_mut(np)?.guides = guides; + if let NodeKind::Page { background, flows, prototype } = doc.get(src)?.kind.clone() + && let NodeKind::Page { background: b, flows: f, prototype: pr } = &mut doc.get_mut(np)?.kind { *b = background; *f = flows @@ -115,7 +118,6 @@ pub fn specs() -> Vec { .filter_map(|fl| copies.iter().find(|(old, _)| *old == fl.start).map(|(_, new)| Flow { start: *new, ..fl })) .collect(); *pr = prototype; - *g = guides; } // Keep the new page right after the source. if let Some(si) = doc.pages.iter().position(|x| *x == src) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 811724f2d..b330b8e4e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -616,13 +616,11 @@ fn document_colors_are_counted_per_page() { assert!(v["colors"].as_array().unwrap().is_empty()); } +/// `guide.add`, `guide.move` and `guide.remove` edit the current page's guides by index. #[test] fn guides_add_move_remove() { let mut s = sess(); - let guides = |s: &Session| match &s.doc().unwrap().node(s.page().unwrap()).unwrap().kind { - NodeKind::Page { guides, .. } => guides.clone(), - _ => vec![], - }; + let guides = |s: &Session| s.doc().unwrap().node(s.page().unwrap()).unwrap().guides.clone(); let r = s.execute("guide.add", &json!({"vertical": true, "offset": 120})).unwrap(); assert_eq!(r["index"], json!(0)); s.execute("guide.add", &json!({"vertical": false, "offset": 40.5})).unwrap(); diff --git a/crates/engine/src/theme_pages_tests.rs b/crates/engine/src/theme_pages_tests.rs index 5172bb4b6..1ebd78b01 100644 --- a/crates/engine/src/theme_pages_tests.rs +++ b/crates/engine/src/theme_pages_tests.rs @@ -183,6 +183,8 @@ fn text_drawn_on_a_dark_page_is_white_and_on_a_light_page_black() { assert_eq!(s.doc().unwrap().node(made).unwrap().fills, vec![Paint::solid(Color::WHITE)]); } +/// `page.duplicate` copies the page's flows (pointing at the copied frames), prototype settings +/// and guides. #[test] fn a_copy_of_a_page_keeps_its_flows_prototype_settings_and_guides() { let mut s = Session::new(); @@ -195,9 +197,12 @@ fn a_copy_of_a_page_keeps_its_flows_prototype_settings_and_guides() { let source = s.page().unwrap(); let copy = id_of(&ex(&mut s, "page.duplicate", json!({}))["id"]).unwrap(); let frames = s.doc().unwrap().node(copy).unwrap().children.clone(); - let state = |s: &Session, id: NodeId| match &s.doc().unwrap().node(id).unwrap().kind { - NodeKind::Page { flows, prototype, guides, .. } => (flows.clone(), prototype.clone(), guides.clone()), - _ => panic!("expected a page"), + let state = |s: &Session, id: NodeId| { + let n = s.doc().unwrap().node(id).unwrap(); + match &n.kind { + NodeKind::Page { flows, prototype, .. } => (flows.clone(), prototype.clone(), n.guides.clone()), + _ => panic!("expected a page"), + } }; let (flows, prototype, guides) = state(&s, copy); let (src_flows, src_prototype, src_guides) = state(&s, source); diff --git a/crates/format/src/fig/export/layer.rs b/crates/format/src/fig/export/layer.rs index 55fd6dd92..4a8604fca 100644 --- a/crates/format/src/fig/export/layer.rs +++ b/crates/format/src/fig/export/layer.rs @@ -19,11 +19,11 @@ impl<'d> Exporter<'d> { ("phase", e("CREATED")), ("parentIndex", o(vec![("guid", parent_guid), ("position", s(&position(index)))])), ]; - let children = if let NodeKind::Page { background, guides, prototype, .. } = &n.kind { + let children = if let NodeKind::Page { background, prototype, .. } = &n.kind { f.extend([("type", e("CANVAS")), ("name", s(&n.name))]); f.extend(root_fields()); f.extend([("backgroundColor", color(*background)), ("backgroundOpacity", fl(background.a.into())), ("backgroundEnabled", b(true))]); - if !guides.is_empty() { + if !n.guides.is_empty() { self.warn("ruler guides were left out"); } if *prototype != Default::default() { diff --git a/crates/format/src/guides_tests.rs b/crates/format/src/guides_tests.rs new file mode 100644 index 000000000..7fcbc8f1f --- /dev/null +++ b/crates/format/src/guides_tests.rs @@ -0,0 +1,45 @@ +//! Ruler guides in `.figcraft` files: a reopened page keeps its guides, and files saved with a +//! page's guides written twice open again (#1476). + +use figcraft_doc::{Document, Guide, NodeKind}; + +use super::*; + +/// A guide: vertical (at an x offset) or horizontal (at a y offset). +fn guide(vertical: bool, offset: f64) -> Guide { + Guide { vertical, offset } +} + +/// Page and frame guides survive saving, reopening, saving the reopened file and opening it again. +#[test] +fn page_and_frame_guides_survive_save_and_reopen() { + let mut d = Document::new("ruler"); + let page = d.first_page().unwrap(); + let frame = d.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); + d.node_mut(page).unwrap().guides_mut().extend([guide(true, 120.0), guide(false, -30.5)]); + d.node_mut(frame).unwrap().guides_mut().push(guide(true, 12.0)); + let (once, issues) = load_bytes(&save_bytes(&d, None).unwrap()).unwrap(); + assert!(issues.is_empty(), "{issues:?}"); + assert_eq!(once.node(page).unwrap().guides(), d.node(page).unwrap().guides()); + assert_eq!(once.node(frame).unwrap().guides(), d.node(frame).unwrap().guides()); + // Saving the reopened file and opening it again (where the file stopped opening). + let text = serde_json::to_string(&to_json(&once).unwrap()).unwrap(); + assert_eq!(text.matches("\"guides\"").count(), 2, "one list on the page, one on the frame: {text}"); + let (twice, _) = load_bytes(&save_bytes(&once, None).unwrap()).unwrap(); + assert_eq!(twice.node(page).unwrap().guides(), d.node(page).unwrap().guides()); +} + +/// A `document.json` with a page's `guides` key twice (as saving a reopened page wrote it) opens +/// with both lists. +#[test] +fn a_file_with_a_page_guides_list_written_twice_opens() { + let mut d = Document::new("twice"); + let page = d.first_page().unwrap(); + d.node_mut(page).unwrap().guides_mut().push(guide(false, 7.0)); + let text = serde_json::to_string(&to_json(&d).unwrap()).unwrap(); + // What saving a reopened page wrote: the guides added since, then the ones the file had. + let broken = text.replacen("\"guides\":[", "\"guides\":[{\"vertical\":true,\"offset\":5.0}],\"guides\":[", 1); + assert_eq!(broken.matches("\"guides\"").count(), 2); + let (back, _) = from_json(&broken).unwrap(); + assert_eq!(back.node(page).unwrap().guides(), &[guide(true, 5.0), guide(false, 7.0)]); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 30f6e5489..634d03631 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -977,6 +977,8 @@ pub fn load_file(path: &std::path::Path) -> Result<(Document, Vec), Form #[cfg(test)] mod branch_tests; #[cfg(test)] +mod guides_tests; +#[cfg(test)] mod image_budget_tests; #[cfg(test)] mod image_tests;