diff --git a/ROADMAP.md b/ROADMAP.md index 596c2c5a..1201c690 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -105,6 +105,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - View ▸ Rotate Spread (`view.rotateSpread`: 90° CW / CCW, 180°, clear): the view turns in quarter turns about its centre; tools, hit testing, the render texture and overlays follow; rulers hide while rotated. InDesign turns just the one spread. - Vertical type (Vertical Type Tool, Type ▸ Story Direction, `object.textFrameOptions {vertical}`, IDML StoryOrientation): lines run top to bottom and follow each other right to left; CJK characters stay upright and other text turns, on screen and in PDF; CJK line breaking with basic kinsoku (also in horizontal text). Vertical punctuation forms (`vert`), tate-chu-yoko and vertical metrics aren't there yet. - Math expressions (Type ▸ Insert Math Expression…, `math.insert`, `math.svg`): a LaTeX subset (fractions, scripts, roots, big operators with limits, Greek, relations and operators with math spacing, `\left…\right`, `\text`) typeset by our own engine into SVG, placed inline at the cursor or as a frame; the LaTeX is kept as alt text and can be edited. +- Object Layer Options for placed PDFs (`object.pdfLayers`, `object.layerOptions`, Object ▸ Object Layer Options…): the file's optional-content layers listed and shown or hidden per frame on screen; in exported PDFs such graphics go out as 300 ppi images of their visible layers. - Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`). - Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export. - Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override. diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 1e046994..e8af7d66 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -811,6 +811,9 @@ pub struct Item { /// Liquid Layout (object-based pages): pins and resize permissions. #[serde(default, skip_serializing_if = "Option::is_none")] pub liquid: Option, + /// Object › Object Layer Options: layers of a placed PDF that are hidden. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub pdf_hidden_layers: Vec, /// Window › Interactive › Media: options of a placed video or sound. #[serde(default, skip_serializing_if = "Option::is_none")] pub media: Option, @@ -866,6 +869,7 @@ impl Item { alt_text: String::new(), xml_tag: String::new(), button: None, + pdf_hidden_layers: Vec::new(), media: None, liquid: None, states: Vec::new(), diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 37179197..0d5e2354 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -229,6 +229,45 @@ fn flatten(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, pp (out, n) } +/// A copy of `d` where placed PDFs with Object Layer Options hidden layers are 300 ppi images. +fn rasterize_layered(d: &designcraft_doc::Document) -> (Option, usize) { + let ids: Vec = + d.all_items().into_iter().filter(|id| d.item(*id).is_some_and(|it| !it.pdf_hidden_layers.is_empty() && it.graphic().is_some())).collect(); + if ids.is_empty() { + return (None, 0); + } + let mut out = d.clone(); + let mut n = 0; + for id in ids { + let Some((g, hidden)) = out.item(id).and_then(|it| Some((it.graphic()?.clone(), it.pdf_hidden_layers.clone()))) else { continue }; + let Some(a) = out.assets.get(&g.asset).cloned() else { continue }; + if !designcraft_render::is_pdf(&a.data) { + continue; + } + let side = (g.size.0.max(g.size.1) * 300.0 / 72.0).clamp(64.0, 8000.0) as u32; + let Some(png) = designcraft_render::pdf_page_png(&a.data, a.page as usize, side, &hidden) else { continue }; + let px = designcraft_render::image_size(&png); + let aid = designcraft_doc::AssetId(out.alloc()); + out.assets.insert( + aid, + std::sync::Arc::new(designcraft_doc::Asset { + page: 0, + id: aid, + name: format!("{}.png", a.name), + mime: "image/png".into(), + link: None, + data: std::sync::Arc::new(png), + pixels: px, + }), + ); + if let Some(designcraft_doc::Content::Graphic(gg)) = out.item_mut(id).map(|it| &mut it.content) { + gg.asset = aid; + } + n += 1; + } + (Some(out), n) +} + fn export_pdf(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let opts = options(p, st.doc.page_count())?; @@ -244,7 +283,10 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result { ), }; let flattened = ppi.map(|ppi| flatten(&st.doc, &s.cache, ppi)); - let doc: &designcraft_doc::Document = flattened.as_ref().map_or(&st.doc, |f| &f.0); + // Placed PDFs with hidden layers go out as images showing just their visible layers. + let base: &designcraft_doc::Document = flattened.as_ref().map_or(&st.doc, |f| &f.0); + let (layered, layer_count) = rasterize_layered(base); + let doc: &designcraft_doc::Document = layered.as_ref().unwrap_or(base); let r = designcraft_pdf::export_pdf_with_report(doc, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?; let mut r = r; // Transparency Blend Space: the page group of pages with transparency. @@ -265,6 +307,9 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result { None => r.warnings.push("page transitions couldn't be added to this PDF".into()), } } + if layer_count > 0 { + r.warnings.push(format!("{layer_count} placed PDF(s) with hidden layers exported as images")); + } if let Some((_, n)) = &flattened && *n > 0 { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 6caad6b0..640fa117 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -36,6 +36,7 @@ mod overrides; mod package; mod path_type; mod paths; +mod pdflayers; mod place_text; pub mod preflight; mod prefs; @@ -166,6 +167,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(linked::specs()); v.extend(mathexpr::specs()); v.extend(media::specs()); + v.extend(pdflayers::specs()); v.extend(liquid::specs()); v.extend(xml::specs()); v.extend(strokes::specs()); diff --git a/crates/engine/src/cmd/pdflayers.rs b/crates/engine/src/cmd/pdflayers.rs new file mode 100644 index 00000000..c8badf53 --- /dev/null +++ b/crates/engine/src/cmd/pdflayers.rs @@ -0,0 +1,119 @@ +//! Object › Object Layer Options: show or hide the layers (optional content) of a placed PDF. + +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, cmd, has_selection, targets}; +use crate::Session; + +fn placed_pdf(s: &Session, id: designcraft_doc::ItemId) -> Option>> { + let d = &s.active()?.doc; + let g = d.item(id)?.graphic()?; + let a = d.assets.get(&g.asset)?; + designcraft_render::is_pdf(&a.data).then(|| a.data.clone()) +} + +pub fn specs() -> Vec { + vec![ + cmd!(query "object.pdfLayers", "Object Layers", [], None, "{id?} → [{name, visible, default}] — the layers of the selected placed PDF", has_selection, |s, p| { + let id = super::id_param(p, "id").or_else(|| s.active().and_then(|d| d.selection.items.first().copied())).ok_or_else(|| bad("object.pdfLayers", "no selection"))?; + let data = placed_pdf(s, id).ok_or_else(|| bad("object.pdfLayers", "not a placed PDF"))?; + let hidden = s.doc()?.doc.item(id).map(|i| i.pdf_hidden_layers.clone()).unwrap_or_default(); + Ok(Value::Array( + designcraft_render::pdf_layers(&data) + .into_iter() + .map(|(name, on)| json!({"visible": if hidden.is_empty() { on } else { !hidden.contains(&name) }, "name": name, "default": on})) + .collect(), + )) + }), + cmd!( + "object.layerOptions", + "Object Layer Options…", + ["Object"], + None, + "{ids?, hidden: [layer name]} — layers of placed PDFs to hide ([] = the file's own settings)", + has_selection, + |s, p| { + let ids = targets(s, p)?; + let hidden: Vec = p + .get("hidden") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) + .unwrap_or_default(); + if ids.iter().all(|id| placed_pdf(s, *id).is_none()) { + return Err(bad("object.layerOptions", "select a placed PDF")); + } + s.edit(|d, _| { + for id in &ids { + if let Some(it) = d.item_mut(*id) { + it.pdf_hidden_layers = hidden.clone(); + } + } + Ok(json!({"hidden": hidden})) + }) + } + ), + ] +} + +#[cfg(test)] +pub(crate) mod tests { + use serde_json::json; + + use crate::Session; + + /// A one-page PDF (100×100 pt) with two layers: "Blue" (left half) and "Red" (right half). + pub(crate) fn two_layer_pdf() -> Vec { + let content = "/OC /a BDC 0 0 1 rg 0 0 50 100 re f EMC /OC /b BDC 1 0 0 rg 50 0 50 100 re f EMC"; + let objs = [ + "<>>>>>".to_string(), + "<>".to_string(), + "<>>>/Contents 4 0 R>>".to_string(), + format!("<>\nstream\n{content}\nendstream", content.len()), + "<>".to_string(), + "<>".to_string(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + let mut t = format!("xref\n0 {}\n0000000000 65535 f\r\n", objs.len() + 1); + for o in offs { + t.push_str(&format!("{o:010} 00000 n\r\n")); + } + t.push_str(&format!("trailer\n<>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1)); + out.extend_from_slice(t.as_bytes()); + out + } + + #[test] + fn placed_pdf_layers_hide() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let pdf = two_layer_pdf(); + let r = s + .execute("file.place", &json!({"base64": super::super::file::base64_encode(&pdf), "name": "art.pdf", "x": 100, "y": 100, "width": 100})) + .unwrap(); + let id = r["id"].clone(); + let l = s.execute("object.pdfLayers", &json!({"id": id})).unwrap(); + assert_eq!(l.as_array().unwrap().len(), 2); + assert_eq!(l[0]["name"], "Blue"); + let px = |s: &Session, x: u32| { + let d = s.doc().unwrap().doc.clone(); + let mut rr = designcraft_render::Renderer::new(); + rr.threads = 0; + rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap().pixel(x, 150) + }; + assert!(px(&s, 125)[2] > 200 && px(&s, 175)[0] > 200, "both layers show"); + s.execute("object.layerOptions", &json!({"ids": [id], "hidden": ["Red"]})).unwrap(); + let right = px(&s, 175); + assert!(right[0] > 200 && right[1] > 200 && right[2] > 200, "the red layer is hidden: {right:?}"); + assert!(px(&s, 125)[2] > 200, "blue still shows"); + assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"], false); + // PDF export carries the choice (the graphic goes out as an image). + let r = s.execute("file.exportPdf", &json!({})).unwrap(); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("layer")), "{r}"); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index d8de06cc..09dc1be6 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -25,6 +25,8 @@ pub use vello_cpu; pub mod damage; mod fx; pub mod glyphs; +mod pdf_layers; +pub use pdf_layers::{pdf_hide_layers, pdf_layers}; pub mod images; mod text; @@ -508,7 +510,13 @@ impl Renderer { let g = &if it.media.is_some() { it.drawn_graphic(&f.doc.assets).unwrap_or_else(|| g.clone()) } else { g.clone() }; ctx.set_transform(f.view * xf); ctx.push_clip_layer(bp); + if !it.pdf_hidden_layers.is_empty() { + HIDDEN_LAYERS.with(|h| *h.borrow_mut() = it.pdf_hidden_layers.clone()); + } self.draw_graphic(ctx, f, g, xf); + if !it.pdf_hidden_layers.is_empty() { + HIDDEN_LAYERS.with(|h| h.borrow_mut().clear()); + } ctx.pop_layer(); } Content::Text(tf) => { @@ -663,7 +671,9 @@ impl Renderer { // Pick a mip level close to the on-screen size (Typical: a 72 ppi proxy). let on_screen = (f.view * xf * g.xf).determinant().abs().sqrt(); let on_screen = if f.opts.quality == DisplayQuality::Typical { on_screen.min(1.0) } else { on_screen }; - let Some(pm) = images::mip(&asset.data, asset.page, g.size.0, on_screen) else { return }; + let hidden = HIDDEN_LAYERS.with(|h| h.borrow().clone()); + let data = if !hidden.is_empty() && is_pdf(&asset.data) { pdf_layers::layered(&asset.data, &hidden) } else { asset.data.clone() }; + let Some(pm) = images::mip(&data, asset.page, g.size.0, on_screen) else { return }; let rect = Rect::new(0.0, 0.0, g.size.0, g.size.1); ctx.set_transform(f.view * xf * g.xf); let sx = g.size.0 / pm.width().max(1) as f64; @@ -683,6 +693,8 @@ fn rect_overlaps(a: Rect, b: Rect) -> bool { } thread_local! { + /// Object Layer Options of the placed PDF being drawn. + static HIDDEN_LAYERS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; /// Appearance of Black for the render on this thread: 100% K as pure black. static RICH_BLACK: std::cell::Cell = const { std::cell::Cell::new(false) }; } @@ -866,6 +878,14 @@ pub fn pdf_page_size(bytes: &[u8], page: usize) -> Option<(f64, f64)> { Some((w as f64, h as f64)) } +/// PDF page `page` as PNG, its longer side about `max_side` pixels, with the `hidden` layers off. +pub fn pdf_page_png(bytes: &[u8], page: usize, max_side: u32, hidden: &[String]) -> Option> { + let data = if hidden.is_empty() { bytes.to_vec() } else { pdf_hide_layers(bytes, hidden).unwrap_or_else(|| bytes.to_vec()) }; + let pm = render_pdf_page(&data, page, max_side)?; + let pixels: Vec = pm.data().iter().flat_map(|p| [p.r, p.g, p.b, p.a]).collect(); + Some(Rendered { width: pm.width() as u32, height: pm.height() as u32, pixels }.to_png()) +} + /// Rasterize PDF page `page` so its longer side is about `max_side` pixels (screen display; PDF /// export embeds the page as vectors). pub fn render_pdf_page(bytes: &[u8], page: usize, max_side: u32) -> Option { diff --git a/crates/render/src/pdf_layers.rs b/crates/render/src/pdf_layers.rs new file mode 100644 index 00000000..344585e3 --- /dev/null +++ b/crates/render/src/pdf_layers.rs @@ -0,0 +1,103 @@ +//! Object Layer Options for placed PDFs: the file's optional-content layers, and a variant of the +//! file with some layers off (an appended catalog whose default configuration turns them off, +//! which the PDF renderer honours). + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use hayro::hayro_syntax::Pdf; +use hayro::hayro_syntax::object::{Array, Dict, Name, ObjectIdentifier}; + +/// (object number, name, on by default) of each layer. +fn ocgs(pdf: &Pdf) -> Vec<(i32, String, bool)> { + let xref = pdf.xref(); + let Some(cat) = xref.get::>(xref.root_id()) else { return vec![] }; + let Some(ocp) = cat.get::>(b"OCProperties") else { return vec![] }; + let Some(list) = ocp.get::>(b"OCGs") else { return vec![] }; + let cfg = ocp.get::>(b"D"); + let ids_in = |key: &[u8]| -> Vec { + cfg.as_ref() + .and_then(|c| c.get::>(key)) + .map(|a| a.raw_iter().filter_map(|o| o.as_obj_ref()).map(|r| r.obj_number).collect()) + .unwrap_or_default() + }; + let base_off = cfg.as_ref().and_then(|c| c.get::>(b"BaseState")).is_some_and(|n| n.as_ref() == b"OFF"); + let (on, off) = (ids_in(b"ON"), ids_in(b"OFF")); + list.raw_iter() + .filter_map(|o| o.as_obj_ref()) + .filter_map(|r| { + let d = xref.get::>(ObjectIdentifier::from(r))?; + let name = d + .get::>(b"Name") + .map(|s| String::from_utf8_lossy(s.as_bytes()).to_string()) + .unwrap_or_default(); + let visible = if base_off { on.contains(&r.obj_number) } else { !off.contains(&r.obj_number) }; + Some((r.obj_number, name, visible)) + }) + .collect() +} + +/// The layers of a placed PDF: (name, visible by default). +pub fn pdf_layers(bytes: &[u8]) -> Vec<(String, bool)> { + Pdf::new(Arc::new(bytes.to_vec())).map(|p| ocgs(&p).into_iter().map(|(_, n, v)| (n, v)).collect()).unwrap_or_default() +} + +/// `bytes` with exactly the layers named in `hidden` off (the others on). `None` when the file +/// has no such layers or can't be read. +pub fn pdf_hide_layers(bytes: &[u8], hidden: &[String]) -> Option> { + let pdf = Pdf::new(Arc::new(bytes.to_vec())).ok()?; + let layers = ocgs(&pdf); + if layers.is_empty() { + return None; + } + let xref = pdf.xref(); + let cat = xref.get::>(xref.root_id())?; + let pages = cat.get_ref(b"Pages")?; + let all: Vec = layers.iter().map(|(n, _, _)| format!("{n} 0 R")).collect(); + let off: Vec = layers.iter().filter(|(_, name, _)| hidden.contains(name)).map(|(n, _, _)| format!("{n} 0 R")).collect(); + // A new catalog under a fresh object number, with the original's page tree. + let root = 9_999_000; + let mut out = bytes.to_vec(); + if !out.ends_with(b"\n") { + out.push(b'\n'); + } + let at = out.len(); + out.extend_from_slice( + format!( + "{root} 0 obj\n<>>>>>\nendobj\n", + pages.obj_number, + pages.gen_number, + all.join(" "), + off.join(" ") + ) + .as_bytes(), + ); + let xref_at = out.len(); + let prev = { + let tail = String::from_utf8_lossy(&bytes[bytes.len().saturating_sub(64)..]).to_string(); + tail.rsplit("startxref").next().and_then(|t| t.split_whitespace().next()).and_then(|n| n.parse::().ok())? + }; + out.extend_from_slice( + format!("xref\n{root} 1\n{at:010} 00000 n\r\ntrailer\n<>\nstartxref\n{xref_at}\n%%EOF\n", root + 1) + .as_bytes(), + ); + Some(out) +} + +/// Shared copies of layer variants (by source address and hidden set). +pub(crate) fn layered(data: &Arc>, hidden: &[String]) -> Arc> { + type Key = (usize, Vec); + static CACHE: Mutex>>>> = Mutex::new(None); + let key = (Arc::as_ptr(data) as usize, hidden.to_vec()); + let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let map = c.get_or_insert_with(HashMap::new); + if let Some(v) = map.get(&key) { + return v.clone(); + } + if map.len() > 64 { + map.clear(); + } + let v = pdf_hide_layers(data, hidden).map(Arc::new).unwrap_or_else(|| data.clone()); + map.insert(key, v.clone()); + v +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b3f60708..6ed80d85 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -730,6 +730,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "print" => "Print", "pdfImport" => "Place PDF", "colorSettings" => "Color Settings", + "layerOptions" => "Object Layer Options", "keyboardShortcuts" => "Keyboard Shortcuts", "colorPicker" => "Color Picker", id => match id.strip_prefix("cmd:").and_then(designcraft_engine::find_command) { @@ -950,6 +951,19 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }); }); } + "layerOptions" => { + let mut layers = d.fields.get("layers").and_then(Value::as_array).cloned().unwrap_or_default(); + if layers.is_empty() { + ui.label("This PDF has no layers."); + } + for l in &mut layers { + let mut on = l["visible"].as_bool().unwrap_or(true); + if ui.checkbox(&mut on, l["name"].as_str().unwrap_or("")).changed() { + l["visible"] = json!(on); + } + } + d.fields.insert("layers".into(), json!(layers)); + } "colorSettings" => { let profiles = d.fields.get("profiles").and_then(Value::as_array).cloned().unwrap_or_default(); egui::Grid::new("color_settings").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { @@ -1324,6 +1338,10 @@ pub fn confirm(app: &mut DesignApp) -> Result { let hex = d.s("hex"); app.run("object.color", json!({"color": hex, "target": d.s("target")})) } + "layerOptions" => { + let hidden: Vec = d.fields.get("layers").and_then(Value::as_array).map(|a| a.iter().filter(|l| l["visible"] == false).map(|l| l["name"].clone()).collect()).unwrap_or_default(); + app.run("object.layerOptions", json!({"hidden": hidden})) + } "colorSettings" => { let r = app.run("color.settings", json!({"rgb": d.s("rgb"), "cmyk": d.s("cmyk"), "intent": d.s("intent"), "bpc": d.b("bpc")}))?; app.ui.color_settings = Some(designcraft_color::cms::active_settings()); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 4502d93b..aa5b2c2a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -77,6 +77,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("window.hidePanelsExceptTools", "Show/Hide Panels Except Tools", Some("Shift+Tab"), "{}"), ("window.nextDocument", "Next Document", Some("Cmd+F6"), "{}"), ("window.previousDocument", "Previous Document", Some("Cmd+Shift+F6"), "{}"), + ("app.layerOptions", "Object Layer Options…", None, "{} — show or hide the layers of the selected placed PDF"), ("view.rotateSpread", "Rotate Spread", None, "{angle: 90 (clockwise) | -90 | 180 | 0 (clear)} — turn the view in quarter turns"), ("view.proofColors", "Proof Colors", None, "{on?: bool} — simulate the proof target on screen"), ( @@ -433,6 +434,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:object.showAll", "-", "cmd:object.textFrameOptions", + "ui:app.layerOptions", "cmd:object.primaryTextFrame", "cmd:object.cornerOptions", "-", @@ -1012,6 +1014,14 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option { + let layers = match app.session.execute("object.pdfLayers", &json!({})) { + Ok(v) => v, + Err(e) => return Some(Err(e.to_string())), + }; + app.ui.dialog = Some(crate::dialogs::Dialog::new("layerOptions", json!({"layers": layers}))); + Ok(Value::Null) + } "view.rotateSpread" => { let angle = p.get("angle").and_then(Value::as_i64).unwrap_or(90); let (Some(rect), Some(v)) = (app.canvas_rect, app.view().copied()) else { return Some(Err("no view".into())) }; diff --git a/docs/parity.md b/docs/parity.md index d924cd06..3b3a4795 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -11,13 +11,13 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia |---|---|---|---|---|---| | P0 | 102 | 100 | 2 | 0 | 99% | | P1 | 97 | 94 | 3 | 0 | 98% | -| P2 | 76 | 44 | 31 | 1 | 78% | +| P2 | 76 | 45 | 31 | 0 | 80% | | Area | Parity | |---|---| | Application shell & workspace | 96% | | Documents, pages, spreads | 98% | -| Layers | 95% | +| Layers | 100% | | Frames, shapes & paths | 100% | | Transform | 100% | | Fill, stroke, colour | 99% | @@ -93,7 +93,7 @@ Open P0 items: Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print) | Layers | Hide/Lock others | P1 | D | | Layers | Page item names in Layers panel | P1 | D | | Layers | Layer colour drives selection colour | P0 | D | -| Layers | Object Layer Options for placed files | P2 | M | +| Layers | Object Layer Options for placed files | P2 | D | | Frames, shapes & paths | Rectangle/Ellipse/Polygon frames | P0 | D | | Frames, shapes & paths | Rectangle/Ellipse/Polygon shapes | P0 | D | | Frames, shapes & paths | Polygon settings | P0 | D |