Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0d5d72d90 | ||
|
|
c3b73f0146 | ||
|
|
ef99ab8d1c | ||
|
|
a89f914119 | ||
|
|
50bb36ebc2 |
No files matched your search
+1
-1
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
|
||||
|
||||
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
|
||||
|
||||
_Last updated: 2026-10-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
|
||||
_Last updated: 2026-10-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
|
||||
|
||||
## Where we are
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ pub(super) const OPTIONS: &[FormatOption] = &[
|
||||
name: "createLayers",
|
||||
ty: "boolean",
|
||||
default: "false",
|
||||
description: "top-level layers as PDF layers (optional content groups with their visibility, print state and lock; PDF 1.5 or later)",
|
||||
description: "layers and sublayers as PDF layers (optional content groups with their visibility, print state and lock, sublayers listed under their layers; PDF 1.5 or later)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "flattenerPreset",
|
||||
@@ -109,7 +109,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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), 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; 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), 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 top-level layer but template layers as a PDF layer, an optional content group: 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). 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 always), 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: true (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), 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}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet c
Line truncated
|
||||
"{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), 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; 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), 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). 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 always), 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: true (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), 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}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options ac
Line truncated
|
||||
has_doc,
|
||||
export_pdf
|
||||
),
|
||||
|
||||
@@ -306,11 +306,16 @@ fn blank_pages(doc: &Document) -> Document {
|
||||
const NOT_PDF_COMPATIBLE: &str = "saved without PDF content: VectorCraft opens it as before, other apps show empty pages";
|
||||
|
||||
/// The encoder's params for a `.ai` file, from a save's or an export's: a PDF of every artboard
|
||||
/// with the PDF options, always carrying the native document; Use Compression compresses the
|
||||
/// PDF's content.
|
||||
/// with the PDF options, always carrying the native document, its layers and sublayers PDF layers
|
||||
/// (hidden ones off) unless asked otherwise; Use Compression compresses the PDF's content.
|
||||
pub(super) fn ai_params(params: &mut Value) {
|
||||
let Some(o) = params.as_object_mut() else { return };
|
||||
o.extend([("preserveEditing".into(), json!(true)), ("range".into(), json!("all"))]);
|
||||
// Apps that read the PDF part (not the native document) then find the layers, the hidden ones
|
||||
// with their art (#372); a PDF standard decides for itself.
|
||||
if o.get("standard").is_none_or(Value::is_null) {
|
||||
o.entry("createLayers").or_insert(json!(true));
|
||||
}
|
||||
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() {
|
||||
|
||||
@@ -59,3 +59,35 @@ fn export_pdf_overprint_is_preserved_or_discarded() {
|
||||
assert_eq!(overprints(&mut s, json!({"compression": {"compressText": false}})), 1);
|
||||
assert_eq!(overprints(&mut s, json!({"advanced": {"overprint": "discard"}})), 0);
|
||||
}
|
||||
|
||||
/// A `.ai` file's PDF part, which apps that don't read the native document open, has the layers
|
||||
/// and sublayers as PDF layers, the hidden ones with their art (#372).
|
||||
#[test]
|
||||
fn ai_files_write_their_layers_and_sublayers_as_pdf_layers() {
|
||||
let mut s = session();
|
||||
let layer1 = s.execute("document.inspect", &json!({})).unwrap()["layers"][1]["id"].as_u64().unwrap();
|
||||
let sub = s.execute("layer.newSublayer", &json!({"parent": layer1, "name": "Sketch"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("layer.setCurrent", &json!({"id": sub})).unwrap();
|
||||
s.execute("shape.ellipse", &json!({"x": 10, "y": 40, "width": 20, "height": 20})).unwrap();
|
||||
s.execute("layer.setProps", &json!({"id": sub, "visible": false})).unwrap();
|
||||
// Name, shown and what it holds (sublayers by name), top-level layers in paint order.
|
||||
fn rows(layers: &[std::sync::Arc<vectorcraft_doc::Node>]) -> Vec<(String, bool, Vec<String>)> {
|
||||
layers
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let held =
|
||||
l.children().into_iter().flatten().map(|c| c.name.clone().filter(|_| c.is_layer()).unwrap_or_else(|| "art".into())).collect();
|
||||
(l.name.clone().unwrap_or_default(), l.visible, held)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
let v = s.execute("document.export", &json!({"format": "ai"})).unwrap();
|
||||
let back = vectorcraft_pdf::import(&b64(&v)).unwrap();
|
||||
let art = || "art".to_string();
|
||||
assert_eq!(rows(&back.layers), [("Layer 1".to_string(), true, vec![art(), "Sketch".into()]), ("Notes".to_string(), false, vec![art()])]);
|
||||
let sketch = back.layers.first().and_then(|l| l.children()?.last().cloned()).unwrap();
|
||||
assert_eq!(rows(&[sketch]), [("Sketch".to_string(), false, vec![art()])]);
|
||||
// Asked not to, it writes none.
|
||||
let v = s.execute("document.export", &json!({"format": "ai", "createLayers": false})).unwrap();
|
||||
assert_eq!(vectorcraft_pdf::import(&b64(&v)).unwrap().layers.len(), 1);
|
||||
}
|
||||
@@ -205,6 +205,7 @@ impl Session {
|
||||
slices_locked: self.menu.slices_locked,
|
||||
auto_add_delete: !self.prefs.disable_auto_add_delete,
|
||||
selection_tolerance: self.prefs.selection_tolerance,
|
||||
anchor_size: self.prefs.anchor_size,
|
||||
path_only: self.prefs.object_selection_by_path_only,
|
||||
type_path_only: self.prefs.type_selection_by_path_only,
|
||||
double_click_isolate: self.prefs.double_click_to_isolate,
|
||||
|
||||
+18
-10
@@ -453,11 +453,13 @@ pub(crate) struct Exporter<'a> {
|
||||
/// The fonts real text is written in, by face id, with their units per em; `None` for faces
|
||||
/// that can't be embedded.
|
||||
fonts: HashMap<u32, Option<(krilla::text::Font, f64)>>,
|
||||
/// Top-level layers are drawn as forms marked for their optional content groups
|
||||
/// Layers and sublayers are drawn as forms marked for their optional content groups
|
||||
/// ([`crate::forms`]).
|
||||
pub layers: bool,
|
||||
/// A top-level layer's form is being drawn.
|
||||
in_layer: bool,
|
||||
/// Each layer's index in [`crate::forms::pdf_layers`], by address.
|
||||
layer_index: HashMap<usize, usize>,
|
||||
/// The address of the layer whose form is being drawn.
|
||||
in_layer: usize,
|
||||
/// Overprinting fills and strokes are drawn as forms marked to overprint.
|
||||
pub overprint: bool,
|
||||
/// An overprinting fill or stroke was drawn so.
|
||||
@@ -485,7 +487,8 @@ impl<'a> Exporter<'a> {
|
||||
outline_text: set.advanced.outline_text,
|
||||
fonts: HashMap::new(),
|
||||
layers: false,
|
||||
in_layer: false,
|
||||
layer_index: crate::forms::pdf_layers(doc).iter().enumerate().map(|(i, l)| (address(l.node), i)).collect(),
|
||||
in_layer: 0,
|
||||
overprint: false,
|
||||
overprinted: false,
|
||||
}
|
||||
@@ -601,6 +604,11 @@ fn constant_mask(s: &mut Surface, page: Rect, alpha: f32) -> krilla::mask::Mask
|
||||
krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Alpha)
|
||||
}
|
||||
|
||||
/// A node's address, to tell the very node apart from equal ones.
|
||||
fn address(n: &Node) -> usize {
|
||||
std::ptr::from_ref(n).addr()
|
||||
}
|
||||
|
||||
fn rects_overlap(a: Rect, b: Rect) -> bool {
|
||||
a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1
|
||||
}
|
||||
@@ -885,15 +893,15 @@ impl Exporter<'_> {
|
||||
|
||||
fn node(&mut self, s: &mut Surface, n: &Node, page: Rect, force: bool) {
|
||||
if self.layers
|
||||
&& !self.in_layer
|
||||
&& let NodeKind::Layer { template: false, printable, .. } = n.kind
|
||||
&& self.in_layer != address(n)
|
||||
&& let NodeKind::Layer { printable, .. } = n.kind
|
||||
&& (printable || self.non_printing)
|
||||
&& let Some(i) = self.doc.layers.iter().position(|l| std::ptr::eq(&**l, n))
|
||||
&& let Some(&i) = self.layer_index.get(&address(n))
|
||||
{
|
||||
// A PDF layer: hidden layers are written too (their group is off).
|
||||
self.in_layer = true;
|
||||
// A PDF layer: hidden layers and sublayers are written too (their group is off).
|
||||
let enclosing = std::mem::replace(&mut self.in_layer, address(n));
|
||||
crate::forms::form(s, Mark::Layer(i), |s| self.node(s, n, page, true));
|
||||
self.in_layer = false;
|
||||
self.in_layer = enclosing;
|
||||
return;
|
||||
}
|
||||
if !force && !n.visible {
|
||||
|
||||
+78
-11
@@ -11,7 +11,9 @@ use std::io::Read;
|
||||
use krilla::geom::PathBuilder;
|
||||
use krilla::graphic::Graphic;
|
||||
use krilla::surface::Surface;
|
||||
use vectorcraft_doc::{Document, NodeKind};
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Document, Node, NodeKind};
|
||||
|
||||
use crate::encrypt::{Lexer, Obj, StreamSpans, Tok, int, stream_spans};
|
||||
use crate::output::{deflate, text_string};
|
||||
@@ -21,12 +23,36 @@ use crate::{PdfError, PdfSettings, Standard};
|
||||
/// What a marked form is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Mark {
|
||||
/// The art of top-level layer `i` (`Document::layers`).
|
||||
/// The art of layer `i` of [`pdf_layers`].
|
||||
Layer(usize),
|
||||
/// A fill or stroke that overprints.
|
||||
Overprint,
|
||||
}
|
||||
|
||||
/// A layer written as a PDF layer (an optional content group).
|
||||
pub(crate) struct PdfLayer<'a> {
|
||||
pub node: &'a Node,
|
||||
/// Its parent layer's index in [`pdf_layers`] (none for a top-level layer).
|
||||
pub parent: Option<usize>,
|
||||
}
|
||||
|
||||
/// The layers written as PDF layers: the top-level layers and their sublayers, but template layers
|
||||
/// (and what they hold), each before its sublayers, in paint order (bottom first).
|
||||
pub(crate) fn pdf_layers(doc: &Document) -> Vec<PdfLayer<'_>> {
|
||||
fn add<'a>(nodes: &'a [Arc<Node>], parent: Option<usize>, out: &mut Vec<PdfLayer<'a>>) {
|
||||
for n in nodes {
|
||||
if let NodeKind::Layer { template: false, children, .. } = &n.kind {
|
||||
let i = out.len();
|
||||
out.push(PdfLayer { node: n, parent });
|
||||
add(children, Some(i), out);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
add(&doc.layers, None, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// The x coordinate of a layer mark's first point (its y is the layer index).
|
||||
const LAYER_X: i32 = -32000;
|
||||
/// The x coordinate of an overprint mark's first point.
|
||||
@@ -199,7 +225,7 @@ fn mark_layers(content: &[u8], forms: &HashMap<&[u8], usize>) -> Option<(Vec<u8>
|
||||
}
|
||||
|
||||
/// Finish the marked forms of `pdf` (as [`crate::export`] writes `doc` with `set`): with
|
||||
/// `layers`, each top-level layer (but template layers) becomes an optional content group —
|
||||
/// `layers`, each layer and sublayer (but template layers) becomes an optional content group —
|
||||
/// named as the layer, off when it is hidden, not printed (`/PrintState /OFF`) when its Print
|
||||
/// option is off, locked when it is locked — and the uses of its forms its optional content;
|
||||
/// with `overprint`, the overprinting forms get a graphics state that overprints (`/OP`, `/op`,
|
||||
@@ -228,6 +254,11 @@ pub(crate) fn finish(pdf: Vec<u8>, doc: &Document, set: &PdfSettings, layers: bo
|
||||
}
|
||||
if layers {
|
||||
write_layers(&pdf, &xref, &objects, &marks, &mut patch, doc, set)?;
|
||||
for (n, _) in marks.iter().filter(|(_, m)| matches!(m, Mark::Layer(_))) {
|
||||
if let Some(o) = objects.get(n) {
|
||||
unbox(&pdf, o, &mut patch)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
let pdf = patch.apply(&pdf, &xref)?;
|
||||
// The file still is what its standard says.
|
||||
@@ -235,8 +266,9 @@ pub(crate) fn finish(pdf: Vec<u8>, doc: &Document, set: &PdfSettings, layers: bo
|
||||
Ok(pdf)
|
||||
}
|
||||
|
||||
/// The optional content groups of `doc`'s top-level layers, the uses of their forms marked as
|
||||
/// their content, and the catalog's `/OCProperties`.
|
||||
/// The optional content groups of `doc`'s layers and sublayers ([`pdf_layers`]), the uses of their
|
||||
/// forms marked as their content, and the catalog's `/OCProperties`, listing sublayers under their
|
||||
/// layers.
|
||||
fn write_layers(
|
||||
pdf: &[u8],
|
||||
xref: &Xref,
|
||||
@@ -247,11 +279,16 @@ fn write_layers(
|
||||
set: &PdfSettings,
|
||||
) -> Result<(), PdfError> {
|
||||
// One group per layer, in paint order (bottom first).
|
||||
let layers = pdf_layers(doc);
|
||||
let mut groups: HashMap<usize, u32> = HashMap::new();
|
||||
let (mut all, mut off, mut locked) = (vec![], vec![], vec![]);
|
||||
for (i, l) in doc.layers.iter().enumerate() {
|
||||
let NodeKind::Layer { template: false, printable, .. } = l.kind else { continue };
|
||||
let name = l.name.clone().unwrap_or_else(|| format!("Layer {}", i + 1));
|
||||
// Each layer's sublayers, and the top-level layers, in paint order.
|
||||
let mut children: HashMap<Option<usize>, Vec<usize>> = HashMap::new();
|
||||
for (i, PdfLayer { node: l, parent }) in layers.iter().enumerate() {
|
||||
let siblings = children.entry(*parent).or_default();
|
||||
siblings.push(i);
|
||||
let name = l.name.clone().unwrap_or_else(|| format!("Layer {}", siblings.len()));
|
||||
let printable = matches!(l.kind, NodeKind::Layer { printable: true, .. });
|
||||
let on = |b: bool| if b { "ON" } else { "OFF" };
|
||||
let g = patch.add_object(
|
||||
format!("<</Type/OCG/Name{}/Usage<</View<</ViewState/{}>>/Print<</PrintState/{}>>>>>>", text_string(&name), on(l.visible), on(printable))
|
||||
@@ -295,9 +332,7 @@ fn write_layers(
|
||||
add_resources(patch, res, "Properties", &entries)?;
|
||||
}
|
||||
let refs = |v: &[u32]| v.iter().map(|g| format!("{g} 0 R")).collect::<Vec<_>>().join(" ");
|
||||
// Layers panels list the top layer first.
|
||||
let order: Vec<u32> = all.iter().rev().copied().collect();
|
||||
let mut config = format!("/Name(Layers)/Order[{}]", refs(&order));
|
||||
let mut config = format!("/Name(Layers)/Order[{}]", order(&children, &groups, None));
|
||||
if !off.is_empty() {
|
||||
config.push_str(&format!("/OFF[{}]", refs(&off)));
|
||||
}
|
||||
@@ -313,3 +348,35 @@ fn write_layers(
|
||||
patch.replace(start + 2, start + 2, format!("/OCProperties<</OCGs[{}]/D<<{config}>>>>", refs(&all)).into_bytes());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A form's box as large as a page can be: the box of a layer's form is only the bounds of its
|
||||
/// art, and a reader that makes a clipping group of a form's box (as importers do) would gather a
|
||||
/// layer's art and its sublayers' in one group, which no layer can hold. A box round the whole
|
||||
/// page clips nothing.
|
||||
const UNBOXED: &[u8] = b"[-32767 -32767 32767 32767]";
|
||||
|
||||
/// Give the layer's form `o` the [`UNBOXED`] box.
|
||||
fn unbox(pdf: &[u8], o: &Object<'_>, patch: &mut Patch) -> Result<(), PdfError> {
|
||||
let Obj::Dict { start, end, .. } = o.dict else { return Err(bad("a layer's form has no dictionary")) };
|
||||
let dict = pdf.get(start..end).ok_or_else(|| bad("a layer's form can't be read"))?;
|
||||
// The writer writes the box as an array of numbers.
|
||||
let at = dict.windows(5).position(|w| w == b"/BBox").ok_or_else(|| bad("a layer's form has no box"))? + 5;
|
||||
let open = at + dict.get(at..).and_then(|d| d.iter().position(|&c| c == b'[')).ok_or_else(|| bad("a layer's form box isn't an array"))?;
|
||||
let close = open + dict.get(open..).and_then(|d| d.iter().position(|&c| c == b']')).ok_or_else(|| bad("a layer's form box isn't an array"))? + 1;
|
||||
patch.replace(start + open, start + close, UNBOXED.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The `/Order` entries of `parent`'s sublayers (the top-level layers for none), the top one first
|
||||
/// as Layers panels list them, each followed by an array of its own sublayers when it has some.
|
||||
fn order(children: &HashMap<Option<usize>, Vec<usize>>, groups: &HashMap<usize, u32>, parent: Option<usize>) -> String {
|
||||
let mut out = vec![];
|
||||
for &i in children.get(&parent).into_iter().flatten().rev() {
|
||||
let Some(g) = groups.get(&i) else { continue };
|
||||
out.push(format!("{g} 0 R"));
|
||||
if children.contains_key(&Some(i)) {
|
||||
out.push(format!("[{}]", order(children, groups, Some(i))));
|
||||
}
|
||||
}
|
||||
out.join(" ")
|
||||
}
|
||||
@@ -537,14 +537,23 @@ struct Frame {
|
||||
children: Vec<Arc<Node>>,
|
||||
/// The first optional content group entered inside (where the frame's art goes).
|
||||
group: Option<usize>,
|
||||
/// The group each child was drawn in, where the frame's art goes when the frame dissolves
|
||||
/// into its parent (a layer's form drawing its sublayers' forms).
|
||||
child_groups: Vec<Option<usize>>,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
fn new(kind: FrameKind) -> Self {
|
||||
Self { kind, children: vec![], group: None }
|
||||
Self { kind, children: vec![], group: None, child_groups: vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
/// A dissolving frame's `children`, each with the group it was drawn in (`groups`, else the
|
||||
/// frame's `group`).
|
||||
fn grouped(children: Vec<Arc<Node>>, groups: Vec<Option<usize>>, group: Option<usize>) -> impl Iterator<Item = (Arc<Node>, Option<usize>)> {
|
||||
children.into_iter().zip(groups.into_iter().map(move |g| g.or(group)).chain(std::iter::repeat(group)))
|
||||
}
|
||||
|
||||
/// Glyphs drawn consecutively with the same paint, merged into one path.
|
||||
struct GlyphRun {
|
||||
path: BezPath,
|
||||
@@ -846,8 +855,11 @@ impl<'p> Builder<'p> {
|
||||
// A frame's art goes where its first art was drawn (a clip can end in a later group).
|
||||
if root {
|
||||
self.root_groups.push(group.or(current));
|
||||
} else if f.group.is_none() {
|
||||
f.group = group.or(current);
|
||||
} else {
|
||||
f.child_groups.push(group.or(current));
|
||||
if f.group.is_none() {
|
||||
f.group = group.or(current);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -925,7 +937,7 @@ impl<'p> Builder<'p> {
|
||||
let node = match f.kind {
|
||||
FrameKind::Root => None,
|
||||
FrameKind::Skip => {
|
||||
f.children.into_iter().for_each(|c| self.emit(c, group));
|
||||
grouped(f.children, f.child_groups, group).for_each(|(c, g)| self.emit(c, g));
|
||||
None
|
||||
}
|
||||
FrameKind::Clip(clip) => {
|
||||
@@ -934,7 +946,7 @@ impl<'p> Builder<'p> {
|
||||
None
|
||||
} else if noop.is_some_and(|r| bounds(&f.children).is_some_and(|b| contains(r, b))) {
|
||||
// A rectangle around all of its art (a form's box) clips nothing.
|
||||
f.children.into_iter().for_each(|c| self.emit(c, group));
|
||||
grouped(f.children, f.child_groups, group).for_each(|(c, g)| self.emit(c, g));
|
||||
None
|
||||
} else {
|
||||
let mut ch = vec![Arc::new(*clip)];
|
||||
@@ -957,7 +969,7 @@ impl<'p> Builder<'p> {
|
||||
if children.is_empty() {
|
||||
None
|
||||
} else if plain {
|
||||
children.into_iter().for_each(|c| self.emit(c, group));
|
||||
grouped(children, f.child_groups, group).for_each(|(c, g)| self.emit(c, g));
|
||||
None
|
||||
} else if let [only] = children.as_slice()
|
||||
&& !only.is_container()
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! text in embedded subset fonts (Advanced). Colours are converted, tagged with ICC profiles and
|
||||
//! given an output intent as the Output settings say. Hidden objects,
|
||||
//! guides and template layers are skipped, and so are non-printing layers unless asked for.
|
||||
//! Top-level layers can be PDF layers (optional content groups with their visibility, print
|
||||
//! Layers and sublayers can be PDF layers (optional content groups with their visibility, print
|
||||
//! state and lock), and overprinting fills and strokes overprint (Advanced).
|
||||
//! Each page is its artboard (the trim box) grown by the bleed (the bleed box) and by the
|
||||
//! printer's marks around it (the media box), drawn in Registration. [`PdfSettings`] is the Save PDF
|
||||
|
||||
@@ -226,7 +226,7 @@ pub struct PdfSettings {
|
||||
pub fast_web_view: bool,
|
||||
/// The app opens the written file (frontends only; the writer ignores it).
|
||||
pub view_after_saving: bool,
|
||||
/// Write top-level layers as PDF layers (optional content).
|
||||
/// Write layers and sublayers as PDF layers (optional content).
|
||||
pub create_layers: bool,
|
||||
/// Keep the layers whose Print option is off (they are left out otherwise, unless
|
||||
/// `create_layers` is on).
|
||||
@@ -449,7 +449,7 @@ impl PdfSettings {
|
||||
within("advanced.fontSubsetPercent", self.advanced.font_subset_percent, 0.0, 100.0, "%")
|
||||
}
|
||||
|
||||
/// Top-level layers are written as PDF layers: asked for, at a version that has them.
|
||||
/// Layers and sublayers are written as PDF layers: asked for, at a version that has them.
|
||||
pub fn writes_layers(&self) -> bool {
|
||||
self.create_layers && self.compatibility.has_layers()
|
||||
}
|
||||
|
||||
@@ -131,6 +131,57 @@ fn pdf_layers_import_back_as_layers() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The rows of `nodes` in paint order, depth first: `depth name visible` for layers, `depth fill`
|
||||
/// for art.
|
||||
fn tree(nodes: &[std::sync::Arc<Node>], depth: usize, out: &mut Vec<String>) {
|
||||
for n in nodes {
|
||||
match &n.kind {
|
||||
NodeKind::Layer { children, .. } => {
|
||||
out.push(format!("{depth} {} {}", n.name.clone().unwrap_or_default(), n.visible));
|
||||
tree(children, depth + 1, out);
|
||||
}
|
||||
_ => out.push(format!("{depth} {:?}", n.appearance.fill().and_then(|f| f.paint.color()))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sublayers are PDF layers listed under their layer's, hidden ones off with their art, and they
|
||||
/// import back as sublayers (#372: other apps read a `.ai` file's PDF part).
|
||||
#[test]
|
||||
fn sublayers_are_nested_pdf_layers_and_import_back() {
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
d.layers.clear();
|
||||
let layer = |d: &mut Document, name: &str, visible: bool, children: Vec<Node>| {
|
||||
let mut l = Node::layer(d.alloc_id(), name, LayerColor::Preset(0));
|
||||
l.visible = visible;
|
||||
if let NodeKind::Layer { children: c, .. } = &mut l.kind {
|
||||
c.extend(children.into_iter().map(std::sync::Arc::new));
|
||||
}
|
||||
l
|
||||
};
|
||||
let deep = vec![rect(&mut d, 90.0, GREEN)];
|
||||
let deep = layer(&mut d, "Deep", true, deep);
|
||||
let sub = vec![rect(&mut d, 50.0, BLUE), deep];
|
||||
let sub = layer(&mut d, "Sub", false, sub);
|
||||
let main = vec![rect(&mut d, 10.0, RED), sub];
|
||||
let main = layer(&mut d, "Main", true, main);
|
||||
d.layers.push(main.into());
|
||||
let r = export(&d, json!({"createLayers": true, "compression": {"compressText": false}}));
|
||||
oc_properties(&r.bytes, None, |props, xref| {
|
||||
let props = props.expect("/OCProperties");
|
||||
assert_eq!(names(xref, props.get(b"OCGs")), ["Main", "Sub", "Deep"]);
|
||||
assert_eq!(names(xref, props.get::<Dict<'_>>(b"D").unwrap().get(b"OFF")), ["Sub"]);
|
||||
});
|
||||
let text = String::from_utf8_lossy(&r.bytes);
|
||||
let order = text.split("/Order").nth(1).and_then(|o| o.split("]/").next()).unwrap().to_string();
|
||||
assert_eq!(order.matches('[').count(), 3, "each layer's sublayers in an array after it: {order}");
|
||||
let mut got = vec![];
|
||||
tree(&import(&r.bytes).unwrap().layers, 0, &mut got);
|
||||
// Paint order, as it was.
|
||||
let fill = |depth, c: Color| format!("{depth} {:?}", Some(c));
|
||||
assert_eq!(got, ["0 Main true".to_string(), fill(1, RED), "1 Sub false".into(), fill(2, BLUE), "2 Deep true".into(), fill(3, GREEN)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_layers_stay_apart() {
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
|
||||
+41
-11
@@ -201,15 +201,21 @@ fn anchor_owners(cx: &ToolContext, f: fn(&Node) -> bool) -> Vec<NodeId> {
|
||||
v
|
||||
}
|
||||
|
||||
/// Anchor of any visible path or area type frame under `p`, topmost first: (id, si, ai, where it is).
|
||||
/// The anchor within `tol` of `p` nearest to it, of the topmost visible path or area type frame
|
||||
/// that has one: (id, si, ai, where it is).
|
||||
fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, Point)> {
|
||||
let owners = anchor_owners(cx, |n| matches!(n.kind, NodeKind::Path { .. }) || is_area_type(n));
|
||||
owners.into_iter().find_map(|id| {
|
||||
let pd = cx.doc.node(id).and_then(editable_path)?;
|
||||
pd.anchors().find(|(_, _, a)| a.p.distance(p) <= tol).map(|(si, ai, a)| (id, si, ai, a.p))
|
||||
nearest_anchor(&pd, p, tol).map(|(si, ai, a)| (id, si, ai, a))
|
||||
})
|
||||
}
|
||||
|
||||
/// The anchor of `pd` within `tol` of `p` nearest to it: (subpath, anchor, where it is).
|
||||
fn nearest_anchor(pd: &PathData, p: Point, tol: f64) -> Option<(usize, usize, Point)> {
|
||||
pd.anchors().map(|(si, ai, a)| (si, ai, a.p)).filter(|(.., a)| a.distance(p) <= tol).min_by(|x, y| x.2.distance(p).total_cmp(&y.2.distance(p)))
|
||||
}
|
||||
|
||||
/// An edge of an area type frame under `p`, topmost first: (text, the edge's two anchors).
|
||||
fn hit_frame_edge(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, Vec<AnchorRef>)> {
|
||||
anchor_owners(cx, is_area_type).into_iter().find_map(|id| {
|
||||
@@ -290,7 +296,8 @@ impl Tool for DirectSelectionTool {
|
||||
return out;
|
||||
}
|
||||
let p = ev.pos;
|
||||
let tol = cx.pick_tol();
|
||||
// Anchors and handle ends are picked from a little further than segments and objects.
|
||||
let (tol, point) = (cx.pick_tol(), cx.point_tol());
|
||||
match (ev.kind, self.state.clone()) {
|
||||
(PointerKind::Down, _) if self.group => {
|
||||
if let Some(out) = self.guide.press(cx, ev) {
|
||||
@@ -332,7 +339,7 @@ impl Tool for DirectSelectionTool {
|
||||
return vec![];
|
||||
}
|
||||
if let Some(sel) = self.spine.filter(|(id, _)| cx.selection.contains(*id))
|
||||
&& let Some(out) = hit_spine_handle(cx, sel, p, tol)
|
||||
&& let Some(out) = hit_spine_handle(cx, sel, p, point)
|
||||
{
|
||||
self.state = State::SpineHandle { id: sel.0, anchor: sel.1, out };
|
||||
return vec![Action::Begin("Reshape Spine".into())];
|
||||
@@ -343,17 +350,17 @@ impl Tool for DirectSelectionTool {
|
||||
return self.mesh.press(g);
|
||||
}
|
||||
self.mesh.unfocus();
|
||||
if let Some((id, si, ai, out)) = hit_handle(cx, p, tol) {
|
||||
if let Some((id, si, ai, out)) = hit_handle(cx, p, point) {
|
||||
self.state = State::Handle { id, si, ai, out };
|
||||
self.handle_snap = HandleSnap::default();
|
||||
return vec![Action::Begin("Reshape".into())];
|
||||
}
|
||||
if let Some((id, anchor, from)) = hit_spine_point(cx, p, tol) {
|
||||
if let Some((id, anchor, from)) = hit_spine_point(cx, p, point) {
|
||||
self.spine = Some((id, anchor));
|
||||
self.state = State::SpinePoint { id, anchor, from, start: p, began: false };
|
||||
return if cx.selection.contains(id) { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] };
|
||||
}
|
||||
if let Some((id, si, ai, grab)) = hit_anchor(cx, p, tol) {
|
||||
if let Some((id, si, ai, grab)) = hit_anchor(cx, p, point) {
|
||||
if cx.doc.node(id).is_some_and(is_area_type) {
|
||||
return self.press_type_area(cx, id, vec![(si, ai)], p, ev.mods.shift);
|
||||
}
|
||||
@@ -608,14 +615,14 @@ impl Tool for DirectSelectionTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The anchor within the selection tolerance of `p`, of a selected path or the path under `p`:
|
||||
/// what Highlight anchors on mouse over marks (a press there picks it).
|
||||
/// The anchor within a press's reach of `p` ([`ToolContext::point_tol`]), of a selected path or the
|
||||
/// path under `p`: what Highlight anchors on mouse over marks (a press there picks it).
|
||||
fn hovered_anchor(cx: &ToolContext, p: Point) -> Option<(NodeId, Point)> {
|
||||
let under = hit_test(cx.doc, p, cx.hit_options()).map(|h| h.leaf);
|
||||
let tol = cx.pick_tol();
|
||||
let tol = cx.point_tol();
|
||||
cx.selection.objects.iter().copied().chain(under).find_map(|id| {
|
||||
let path = cx.doc.node(id).and_then(editable_path)?;
|
||||
path.anchors().find(|(_, _, a)| a.p.distance(p) <= tol).map(|(_, _, a)| (id, a.p))
|
||||
nearest_anchor(&path, p, tol).map(|(.., a)| (id, a))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -685,6 +692,29 @@ mod tests {
|
||||
assert_eq!(down(&mut t, 100.0), vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]);
|
||||
}
|
||||
|
||||
/// An anchor is picked from 2 px past its drawn square even where its segment is nearer, and
|
||||
/// further with a larger anchor Size or Tolerance (#593); away from it the segment is.
|
||||
#[test]
|
||||
fn anchors_are_picked_before_the_segments_through_them() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let corner = vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))];
|
||||
let press = |cx: &ToolContext, x: f64, y: f64| DirectSelectionTool::new(false).pointer(cx, &PointerEvent::new(PointerKind::Down, x, y));
|
||||
let c = cx(&d, &s, &p);
|
||||
// 4.3 px from the corner, 0.5 px from the top edge.
|
||||
assert_eq!(press(&c, 104.2, 100.5), corner);
|
||||
assert_ne!(press(&c, 110.0, 100.5), corner, "the segment, away from the anchor");
|
||||
assert_ne!(press(&c, 105.0, 100.5), corner, "past the default reach (4.5 px)");
|
||||
let large = ToolContext { anchor_size: 7, ..cx(&d, &s, &p) };
|
||||
assert_eq!(press(&large, 106.0, 100.5), corner, "Size 7 draws them 9 px wide");
|
||||
let loose = ToolContext { selection_tolerance: 8.0, ..cx(&d, &s, &p) };
|
||||
assert_eq!(press(&loose, 107.5, 100.5), corner);
|
||||
// On screen: at 200% the reach is half as far in the document.
|
||||
let zoomed = ToolContext { zoom: 2.0, ..cx(&d, &s, &p) };
|
||||
assert_eq!(press(&zoomed, 102.1, 100.2), corner);
|
||||
assert_ne!(press(&zoomed, 102.6, 100.2), corner);
|
||||
}
|
||||
|
||||
/// A blend of two 20 pt squares centred on (110, 310) and (210, 310), selected.
|
||||
fn blend_doc() -> (vectorcraft_doc::Document, NodeId, NodeId) {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
|
||||
@@ -48,7 +48,7 @@ impl AnchorTool {
|
||||
}
|
||||
|
||||
fn delete(cx: &ToolContext, p: Point) -> Vec<Action> {
|
||||
hit_anchor(cx, p, cx.pick_tol()).map(remove_anchor).into_iter().collect()
|
||||
hit_anchor(cx, p, cx.point_tol()).map(remove_anchor).into_iter().collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,8 @@ impl Tool for AnchorTool {
|
||||
}
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
let p = ev.pos;
|
||||
let tol = cx.pick_tol();
|
||||
// Anchors and handle ends are picked from a little further than segments.
|
||||
let (tol, point) = (cx.pick_tol(), cx.point_tol());
|
||||
match (self.id, ev.kind) {
|
||||
("addAnchor", PointerKind::Down) => {
|
||||
if ev.mods.alt {
|
||||
@@ -78,7 +79,7 @@ impl Tool for AnchorTool {
|
||||
}
|
||||
}
|
||||
("scissors", PointerKind::Down) => {
|
||||
if let Some((id, si, ai)) = hit_anchor(cx, p, tol) {
|
||||
if let Some((id, si, ai)) = hit_anchor(cx, p, point) {
|
||||
return vec![Action::Exec("path.split".into(), json!({"id": id.0, "subpath": si, "anchor": ai}))];
|
||||
}
|
||||
match hit_segment(cx, p, tol) {
|
||||
@@ -87,10 +88,10 @@ impl Tool for AnchorTool {
|
||||
}
|
||||
}
|
||||
("anchorPoint", PointerKind::Down) => {
|
||||
if let Some((id, si, ai, out)) = hit_handle(cx, p, tol) {
|
||||
if let Some((id, si, ai, out)) = hit_handle(cx, p, point) {
|
||||
self.state = State::Handle { id, si, ai, out, began: false };
|
||||
self.snap = HandleSnap::default();
|
||||
} else if let Some((id, si, ai)) = hit_anchor(cx, p, tol) {
|
||||
} else if let Some((id, si, ai)) = hit_anchor(cx, p, point) {
|
||||
self.state = State::Convert { id, si, ai, start: p, began: false };
|
||||
} else if let Some((id, si, seg, t)) = hit_segment(cx, p, tol) {
|
||||
self.state = State::Reshape { id, si, seg, t, start: p, began: false };
|
||||
|
||||
@@ -274,6 +274,8 @@ pub struct ToolContext<'a> {
|
||||
/// Selection & Anchor Display → Tolerance: how near (screen pixels) a click must be to a path
|
||||
/// or an anchor to pick it.
|
||||
pub selection_tolerance: f64,
|
||||
/// Selection & Anchor Display → Size (1–7, default 3): how big anchors and handles are drawn.
|
||||
pub anchor_size: u32,
|
||||
/// Selection & Anchor Display → Object Selection by Path Only: a click inside a filled path
|
||||
/// doesn't pick it, only one on its path does.
|
||||
pub path_only: bool,
|
||||
@@ -362,6 +364,14 @@ impl ToolContext<'_> {
|
||||
pub fn pick_tol(&self) -> f64 {
|
||||
self.tol(self.selection_tolerance)
|
||||
}
|
||||
/// How near (document units) a press picks an anchor or a direction handle's end: the
|
||||
/// selection tolerance, but at least 2 px past the drawn point, so a point is easy to catch
|
||||
/// and wins over the segments through it, which are tested after it (#593).
|
||||
pub fn point_tol(&self) -> f64 {
|
||||
// Anchors are drawn 5 px wide at the default Size 3, a pixel more or less per step.
|
||||
let half = (5.0 + f64::from(self.anchor_size.clamp(1, 7)) - 3.0) / 2.0;
|
||||
self.tol(self.selection_tolerance.max(half + 2.0))
|
||||
}
|
||||
pub fn hit_options(&self) -> vectorcraft_doc::hit::HitOptions {
|
||||
vectorcraft_doc::hit::HitOptions {
|
||||
tol: self.pick_tol(),
|
||||
@@ -666,6 +676,7 @@ pub(crate) mod testutil {
|
||||
slices_locked: false,
|
||||
auto_add_delete: true,
|
||||
selection_tolerance: 3.0,
|
||||
anchor_size: 3,
|
||||
path_only: false,
|
||||
type_path_only: false,
|
||||
double_click_isolate: true,
|
||||
|
||||
@@ -91,11 +91,11 @@ fn last_anchor(cx: &ToolContext, id: NodeId) -> Option<(usize, usize)> {
|
||||
/// Alt held over a handle end, an anchor or a segment of a selected path: the Anchor Point tool's
|
||||
/// gesture, within its tolerance.
|
||||
fn alt_converts(cx: &ToolContext, p: Point, m: Mods) -> bool {
|
||||
let tol = cx.pick_tol();
|
||||
let point = cx.point_tol();
|
||||
m.alt
|
||||
&& (hit_handle(cx, p, tol).is_some()
|
||||
|| crate::draw2::anchor_in(cx, editable_paths(cx), p, tol).is_some()
|
||||
|| crate::draw2::segment_in(cx, editable_paths(cx), p, tol).is_some())
|
||||
&& (hit_handle(cx, p, point).is_some()
|
||||
|| crate::draw2::anchor_in(cx, editable_paths(cx), p, point).is_some()
|
||||
|| crate::draw2::segment_in(cx, editable_paths(cx), p, cx.pick_tol()).is_some())
|
||||
}
|
||||
|
||||
/// The selected paths the pen edits (not guides, nor locked or hidden ones).
|
||||
@@ -123,7 +123,7 @@ impl Tool for PenTool {
|
||||
self.drag.is_some() || self.handle.is_some() || self.convert.is_some()
|
||||
}
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
let tol = cx.tol(5.0);
|
||||
let tol = cx.tol(5.0).max(cx.point_tol());
|
||||
// The path a press began is the selected one by the next event.
|
||||
if self.drawing && self.path.is_none() {
|
||||
self.path = active_path(cx).map(|a| a.0);
|
||||
@@ -297,7 +297,7 @@ impl Tool for PenTool {
|
||||
o
|
||||
}
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
|
||||
let tol = cx.tol(5.0);
|
||||
let tol = cx.tol(5.0).max(cx.point_tol());
|
||||
if let Some((id, first, last, _)) = self.active(cx) {
|
||||
let one = last_anchor(cx, id).is_some_and(|(_, ai)| ai == 0);
|
||||
if p.distance(first) <= tol && !one {
|
||||
|
||||
@@ -455,7 +455,7 @@ fn general(ui: &mut egui::Ui, d: &mut Dialog, editor: bool) {
|
||||
flag(ui, d, "viewAfterSaving", tl!("View PDF after saving"), true);
|
||||
// PDF layers need PDF 1.5, and PDF/X-1a and PDF/X-3 have none.
|
||||
let layers = standard.allows_layers() && choice::<Compatibility>(d, "compatibility").unwrap_or_default().has_layers();
|
||||
flag(ui, d, "createLayers", tl!("Create PDF layers from top-level layers"), layers);
|
||||
flag(ui, d, "createLayers", tl!("Create PDF layers from layers and sublayers"), layers);
|
||||
flag(ui, d, "includeNonPrinting", tl!("Include non-printing layers"), true);
|
||||
if editor {
|
||||
heading(ui, tl!("Description"));
|
||||
|
||||
@@ -508,7 +508,7 @@
|
||||
Create Object Mosaic… Crear mosaico de objetos…
|
||||
Create Outlines Crear contornos
|
||||
Create Path Crear trazado
|
||||
Create PDF layers from top-level layers Crear capas PDF a partir de las capas de nivel superior
|
||||
Create PDF layers from layers and sublayers Crear capas PDF a partir de las capas y subcapas
|
||||
Create PDF-Compatible File Crear archivo compatible con PDF
|
||||
Create Report Crear informe
|
||||
Create Slice Crear sector
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -508,7 +508,7 @@
|
||||
Create Object Mosaic… Crea mosaico di oggetti…
|
||||
Create Outlines Crea contorni
|
||||
Create Path Crea tracciato
|
||||
Create PDF layers from top-level layers Crea livelli PDF dai livelli principali
|
||||
Create PDF layers from layers and sublayers Crea livelli PDF dai livelli e sottolivelli
|
||||
Create PDF-Compatible File Crea file compatibile PDF
|
||||
Create Report Crea rapporto
|
||||
Create Slice Crea sezione
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -983,7 +983,7 @@
|
||||
Create New Sublayer 新規サブレイヤーを作成
|
||||
Create New Swatch… 新規スウォッチを作成…
|
||||
Create New {kind} Style 新しい{kind}スタイルを作成
|
||||
Create PDF layers from top-level layers 最上位レイヤーから PDF レイヤーを作成
|
||||
Create PDF layers from layers and sublayers レイヤーとサブレイヤーから PDF レイヤーを作成
|
||||
Create PDF-Compatible File PDF 互換ファイルを作成
|
||||
Create Path パスを作成
|
||||
Create Slice スライスを作成
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -497,7 +497,7 @@
|
||||
Create New {kind} Style Criar Novo Estilo de {kind}
|
||||
Create Object Mosaic… Criar Mosaico de Objetos…
|
||||
Create Outlines Criar Contornos
|
||||
Create PDF layers from top-level layers Criar camadas de PDF a partir das camadas de nível superior
|
||||
Create PDF layers from layers and sublayers Criar camadas de PDF a partir das camadas e subcamadas
|
||||
Create PDF-Compatible File Criar Arquivo Compatível com PDF
|
||||
Create Path Criar Caminho
|
||||
Create Slice Criar Fatia
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -506,7 +506,7 @@
|
||||
Create Object Mosaic… Создать мозаику объектов…
|
||||
Create Outlines Создать контуры
|
||||
Create Path Создать контур
|
||||
Create PDF layers from top-level layers Создавать слои PDF из слоёв верхнего уровня
|
||||
Create PDF layers from layers and sublayers Создавать слои PDF из слоёв и подслоёв
|
||||
Create PDF-Compatible File Создать файл, совместимый с PDF
|
||||
Create Report Создать отчёт
|
||||
Create Slice Создать фрагмент
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -508,7 +508,7 @@
|
||||
Create Object Mosaic… 创建对象马赛克…
|
||||
Create Outlines 创建轮廓
|
||||
Create Path 创建路径
|
||||
Create PDF layers from top-level layers 从顶层图层创建 PDF 图层
|
||||
Create PDF layers from layers and sublayers 从图层和子图层创建 PDF 图层
|
||||
Create PDF-Compatible File 创建与 PDF 兼容的文件
|
||||
Create Report 创建报告
|
||||
Create Slice 创建切片
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -523,7 +523,7 @@
|
||||
Create Object Mosaic… 建立物件馬賽克…
|
||||
Create Outlines 建立外框
|
||||
Create Path 建立路徑
|
||||
Create PDF layers from top-level layers 從最上層圖層建立 PDF 圖層
|
||||
Create PDF layers from layers and sublayers 從圖層和子圖層建立 PDF 圖層
|
||||
Create PDF-Compatible File 建立與 PDF 相容的檔案
|
||||
Create Report 建立報告
|
||||
Create Slice 建立切片
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
+5
-3
@@ -355,9 +355,11 @@ A `security` password encrypts the file (RC4 40-bit at PDF 1.3, RC4 128-bit at 1
|
||||
Pattern fills and strokes are written as their tiles clipped to the area they paint (a stroke's outline, with its
|
||||
dashes, caps, profile, arrowheads and alignment), and freeform gradients as an image of their colour field at the
|
||||
document's raster effects resolution, clipped the same way.
|
||||
`createLayers` writes each top-level layer (template layers are left out) as a PDF layer, an optional content group
|
||||
named as the layer: hidden layers are off, non-printing ones have `/PrintState /OFF` and locked ones are locked. It needs
|
||||
PDF 1.5 or later (at 1.4 it warns), and the file reopens with those layers.
|
||||
`createLayers` writes each layer and sublayer (template layers are left out) as a PDF layer, an optional content group
|
||||
named as the layer and listed under its parent layer's: hidden layers are off (their art is written, for the reader to
|
||||
show), non-printing ones have `/PrintState /OFF` and locked ones are locked. It needs PDF 1.5 or later (at 1.4 it
|
||||
warns), and the file reopens with those layers and sublayers. A `.ai` file writes them unless `createLayers` is false,
|
||||
so apps that read its PDF part find the layers, hidden ones included; hidden objects aren't in the PDF part.
|
||||
Images follow the `compression` settings of their kind (`color`, `gray`, or `mono` for black-and-white images): above
|
||||
`abovePpi` as placed they are resampled (`downsample`: `average`, `subsample` or `bicubic`) to `ppi`, and compressed
|
||||
with `zip`, `jpeg` (at `quality`; images with transparency stay lossless) or `auto` (JPEGs stay JPEG, the others
|
||||
|
||||
Reference in new issue
Block a user