mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 01:37:02 -06:00
refactor(pdf): use shared writer for export and print (PDF.W1)
This commit is contained in:
Generated
+11
-3
@@ -806,6 +806,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
|
||||
dependencies = [
|
||||
"krilla",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
@@ -2221,8 +2230,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27da593198b20eeba65caeb73c2bbeec3e53ab08fa549898312ce81c4fce5e33"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bumpalo",
|
||||
@@ -4762,12 +4770,12 @@ name = "vectorcraft-pdf"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"craft-pdf",
|
||||
"flate2",
|
||||
"hayro-interpret",
|
||||
"hayro-syntax",
|
||||
"image",
|
||||
"jpeg-encoder",
|
||||
"krilla",
|
||||
"kurbo 0.13.1",
|
||||
"log",
|
||||
"md-5",
|
||||
|
||||
+4
-1
@@ -11,6 +11,7 @@ rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/vectorcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", default-features = false, features = ["write"] }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
# internal
|
||||
@@ -61,7 +62,6 @@ wat = "1.228"
|
||||
vello_cpu = { version = "0.2", features = ["f32_pipeline"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
|
||||
usvg = "0.48"
|
||||
krilla = "0.8"
|
||||
skrifa = "0.47"
|
||||
harfrust = "0.13"
|
||||
linesweeper = "0.5"
|
||||
@@ -109,3 +109,6 @@ opt-level = 1
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
[patch.crates-io]
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8" }
|
||||
|
||||
+3
-1
@@ -1,12 +1,14 @@
|
||||
# VectorCraft Roadmap
|
||||
|
||||
- PDF.W1: PDF export and print composition use the shared optional writer for configuration, metadata, font preparation and guarded finalization; drawing and output policy remain app-owned.
|
||||
|
||||
- UI.5: Shared docking adds tab reordering, docking into other groups, dock splits and resizable floating panel groups. Panel positions and return locations persist with workspaces; the native icon rail and Tools panel remain available.
|
||||
|
||||
- UI.4: Dock and floating-panel headers use shared accessible tabs with bounded overflow. Widget-state theme application uses `craft-ui`; all four palettes, font stacks and preferences remain app-owned.
|
||||
|
||||
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Image Trace: Shapes, stroke caps, enhanced presets, gradients over noise, #1845 to #1848, headline numbers unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; merges of 2026-10-14: anchor Align and Distribute, #1636; Direct Selection fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks and a Precise Cursors command, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; a guide's position in the Transform panel is a convenience, not a parity claim, #1659; headline numbers unchanged; later merges of 2026-10-13: SVG import of CSS transforms, percentage lengths in symbols, Inkscape flowed text and pages, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder Trim, Merge and Crop strokes, #1570; saves to folders that refuse the temporary file, #1620 in part; OpenType stylistic sets, #1607; intersect Smart Guides, #1602 in part; headline numbers unchanged; control transport deadlines, #1726; Image Trace edges, strokes, memory and three new options, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-13: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; `.ai`, PDF, EPS and SVG import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; headline numbers unchanged; community PRs and fixes of late 2026-10-12: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; French catalog complete again, #1511; headline numbers unchanged; Image Trace colour and photo presets, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-12: Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; many-object edits, #1429 in part; headline numbers unchanged; Image Trace places edges to a fraction of a pixel, measured in `docs/image-trace-quality.md`, headline numbers unchanged; Transform dialogs preview and the transform tools act on direct-selected anchors, #1412 and #1408; `.ai` compound path paint, #1410, overprint, #1388, and text-only PDF parts, #1387; community PRs: strokes wider than their shape, corner drags without the bounding box, pasted pictures, compound paths in `document.inspect`, MCP guide; headline numbers unchanged; community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged; `.ai` art styles and clipping fills from the editing data; headline numbers unchanged; `editingData: "always"`; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Image Trace: Shapes, stroke caps, enhanced presets, gradients over noise, #1845 to #1848, headline numbers unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; merges of 2026-10-14: anchor Align and Distribute, #1636; Direct Selection fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks and a Precise Cursors command, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; a guide's position in the Transform panel is a convenience, not a parity claim, #1659; headline numbers unchanged; later merges of 2026-10-13: SVG import of CSS transforms, percentage lengths in symbols, Inkscape flowed text and pages, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder Trim, Merge and Crop strokes, #1570; saves to folders that refuse the temporary file, #1620 in part; OpenType stylistic sets, #1607; intersect Smart Guides, #1602 in part; headline numbers unchanged; control transport deadlines, #1726; Image Trace edges, strokes, memory and three new options, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-13: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; `.ai`, PDF, EPS and SVG import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; headline numbers unchanged; community PRs and fixes of late 2026-10-12: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; French catalog complete again, #1511; headline numbers unchanged; Image Trace colour and photo presets, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-12: Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; many-object edits, #1429 in part; headline numbers unchanged; Image Trace places edges to a fraction of a pixel, measured in `docs/image-trace-quality.md`, headline numbers unchanged; Transform dialogs preview and the transform tools act on direct-selected anchors, #1412 and #1408; `.ai` compound path paint, #1410, overprint, #1388, and text-only PDF parts, #1387; community PRs: strokes wider than their shape, corner drags without the bounding box, pasted pictures, compound paths in `document.inspect`, MCP guide; headline numbers unchanged; community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged; `.ai` art styles and clipping fills from the editing data; headline numbers unchanged; `editingData: "always"`; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
|
||||
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
|
||||
|
||||
@@ -206,7 +206,7 @@ fn kept_layers(s: &Session, p: &Value, command: &str) -> Result<HashSet<NodeId>>
|
||||
/// on a hidden layer's eye shows just that layer (#1158).
|
||||
fn others(s: &mut Session, p: &Value, flag: Flag, v: bool, command: &str) -> Result<Value> {
|
||||
let keep = kept_layers(s, p, command)?;
|
||||
let changes = s.doc()?.doc.layers.iter().map(|l| (l.id, !keep.contains(&l.id) == v)).collect();
|
||||
let changes = s.doc()?.doc.layers.iter().map(|l| (l.id, keep.contains(&l.id) != v)).collect();
|
||||
set_flag(s, changes, flag, flag.label(v))
|
||||
}
|
||||
|
||||
|
||||
@@ -676,6 +676,7 @@ fn remote_does_not_send_on_a_connection_with_a_stray_line() {
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
let mut remote = Remote::connect(&addr).unwrap();
|
||||
let (mut stale, _) = listener.accept().unwrap();
|
||||
stale.set_read_timeout(Some(std::time::Duration::from_secs(2))).unwrap();
|
||||
writeln!(stale, "{}", json!({"ok": false, "error": "idle"})).unwrap();
|
||||
let probe = remote.connection_probe();
|
||||
probe.set_read_timeout(Some(std::time::Duration::from_secs(2))).unwrap();
|
||||
@@ -693,7 +694,6 @@ fn remote_does_not_send_on_a_connection_with_a_stray_line() {
|
||||
assert_eq!(remote.call("document.inspect", json!({})), Ok(json!(7)));
|
||||
h.join().unwrap();
|
||||
// Nothing was written to the stale connection: it ends (or is reset, its line unread) without a byte.
|
||||
stale.set_read_timeout(Some(std::time::Duration::from_secs(2))).unwrap();
|
||||
let mut rest = String::new();
|
||||
assert_eq!(BufReader::new(stale).read_line(&mut rest).unwrap_or(0), 0, "{rest}");
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ flate2 = { workspace = true }
|
||||
image = { workspace = true }
|
||||
# CMYK JPEGs (the image crate writes RGB and grey ones only).
|
||||
jpeg-encoder = { workspace = true }
|
||||
krilla = { workspace = true }
|
||||
craft-pdf = { workspace = true }
|
||||
hayro-syntax = "0.7.2"
|
||||
hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts", "embed-cmaps"] }
|
||||
log = { workspace = true }
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_pdf::write::backend as krilla;
|
||||
use hayro_syntax::Pdf;
|
||||
use hayro_syntax::object::{Array, Dict, Object, ObjectIdentifier, Stream, String as PdfString};
|
||||
use krilla::embed::{AssociationKind, EmbeddedFile, MimeType};
|
||||
|
||||
+30
-36
@@ -4,12 +4,13 @@ use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_pdf::write::backend as krilla;
|
||||
use craft_pdf::write::{self, Metadata};
|
||||
use krilla::color::separation::{Color as SepColor, SeparationColorant, SeparationSpace};
|
||||
use krilla::color::{cmyk, luma, rgb};
|
||||
use krilla::configure::{Archival, ConfigurationBuilder, PdfVersion};
|
||||
use krilla::configure::{Archival, PdfVersion};
|
||||
use krilla::geom::{Path, PathBuilder, Size, Transform};
|
||||
use krilla::image::Image;
|
||||
use krilla::metadata::Metadata;
|
||||
use krilla::num::NormalizedF32;
|
||||
use krilla::page::PageSettings;
|
||||
use krilla::paint::{Fill, LinearGradient, RadialGradient, SpreadMethod, Stop, Stroke, StrokeDash};
|
||||
@@ -37,6 +38,10 @@ pub fn export(doc: &Document, opts: &PdfOptions) -> Result<Vec<u8>, PdfError> {
|
||||
/// Like [`export`], also returning warnings: options accepted but not applied yet (see
|
||||
/// [`crate::PdfSettings::warnings`]) and features approximated or dropped.
|
||||
pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportReport, PdfError> {
|
||||
write::guard(|| export_with_report_inner(doc, opts))
|
||||
}
|
||||
|
||||
fn export_with_report_inner(doc: &Document, opts: &PdfOptions) -> Result<ExportReport, PdfError> {
|
||||
let set = &opts.settings;
|
||||
set.check()?;
|
||||
// Live geometry effects export as their result; raster effects are reported below.
|
||||
@@ -52,7 +57,7 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
|
||||
let mut warnings = set.warnings();
|
||||
let native = opts.native.as_deref().filter(|_| set.preserve_editing);
|
||||
if let Some(native) = native {
|
||||
w.pdf.embed_file(crate::editing::embedded_file(native, set.compression.compress_text, created_at.map(date_time)));
|
||||
w.pdf.document_mut().embed_file(crate::editing::embedded_file(native, set.compression.compress_text, created_at.map(date_time)));
|
||||
} else if set.preserve_editing {
|
||||
warnings.push("Preserve editing needs the native document, which wasn't given: the PDF reopens as plain artwork".into());
|
||||
}
|
||||
@@ -123,7 +128,7 @@ pub fn page_areas(doc: &Document, opts: &PdfOptions) -> Result<Vec<Rect>, PdfErr
|
||||
/// A PDF being written, shared by export and print: the file's configuration and metadata, then
|
||||
/// its pages ([`Self::page`]), each drawn by an [`Exporter`].
|
||||
pub(crate) struct Writer {
|
||||
pub pdf: krilla::Document,
|
||||
pub pdf: write::Writer,
|
||||
/// How colours are written (the Output settings).
|
||||
pub out: Arc<ColorOut>,
|
||||
/// Groups blend in CMYK (a CMYK document's composite, or converting to CMYK; see
|
||||
@@ -180,12 +185,9 @@ impl Writer {
|
||||
Compatibility::Pdf17 => PdfVersion::Pdf17,
|
||||
Compatibility::Pdf20 => PdfVersion::Pdf20,
|
||||
};
|
||||
let mut cb = ConfigurationBuilder::new().with_version(version);
|
||||
// `check` has refused the standards (and standard/version pairs) the writer can't produce.
|
||||
if set.standard == Standard::PdfA2b {
|
||||
cb = cb.with_archival_validator(Archival::A2_B);
|
||||
}
|
||||
let configuration = cb.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?;
|
||||
let archival = (set.standard == Standard::PdfA2b).then_some(Archival::A2_B);
|
||||
let configuration = write::configuration(version, archival)?;
|
||||
let settings = krilla::SerializeSettings {
|
||||
compress_content_streams: set.compression.compress_text,
|
||||
no_device_cs: out.tagged,
|
||||
@@ -194,27 +196,19 @@ impl Writer {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut pdf = krilla::Document::new_with(settings);
|
||||
let mut meta = Metadata::new().creator("VectorCraft".into()).producer("VectorCraft".into());
|
||||
if !title.is_empty() {
|
||||
meta = meta.title(title.to_string());
|
||||
}
|
||||
// File Info.
|
||||
// File Info normalization stays with the application.
|
||||
let info = &doc.metadata;
|
||||
let given = |s: &str| Some(s.trim()).filter(|s| !s.is_empty()).map(str::to_string);
|
||||
if let Some(a) = given(&info.author) {
|
||||
meta = meta.authors(vec![a]);
|
||||
}
|
||||
if let Some(d) = given(&info.description) {
|
||||
meta = meta.description(d);
|
||||
}
|
||||
if !info.keywords.is_empty() {
|
||||
meta = meta.keywords(info.keywords.clone());
|
||||
}
|
||||
if let Some(t) = created.map(date_time) {
|
||||
meta = meta.creation_date(t);
|
||||
}
|
||||
pdf.set_metadata(meta);
|
||||
let metadata = Metadata {
|
||||
creator: "VectorCraft".into(),
|
||||
producer: "VectorCraft".into(),
|
||||
title: Some(title.to_owned()),
|
||||
authors: given(&info.author).into_iter().collect(),
|
||||
description: given(&info.description),
|
||||
keywords: info.keywords.clone(),
|
||||
created: created.map(date_time),
|
||||
};
|
||||
let pdf = write::Writer::new(settings, metadata);
|
||||
Ok(Self { pdf, cmyk: out.blends_cmyk(cmyk), out: Arc::new(out), lab_spots: vec![], warnings, pdf13: set.pdf13() })
|
||||
}
|
||||
|
||||
@@ -223,7 +217,7 @@ impl Writer {
|
||||
let (w, h) = sheet.size;
|
||||
let size = Size::from_wh(w.max(1.0) as f32, h.max(1.0) as f32).ok_or_else(|| PdfError::BadSetting(format!("page size {w} × {h}")))?;
|
||||
let at = |b: Rect| krilla::geom::Rect::from_ltrb(b.x0 as f32, b.y0 as f32, b.x1 as f32, b.y1 as f32);
|
||||
let mut page = self.pdf.start_page_with(PageSettings::new(size).with_trim_box(at(sheet.trim)).with_bleed_box(at(sheet.bleed)));
|
||||
let mut page = self.pdf.document_mut().start_page_with(PageSettings::new(size).with_trim_box(at(sheet.trim)).with_bleed_box(at(sheet.bleed)));
|
||||
let mut s = page.surface();
|
||||
let paper = Rect::new(0.0, 0.0, w, h);
|
||||
if sheet.negative {
|
||||
@@ -313,7 +307,7 @@ impl Writer {
|
||||
/// standard) and the warnings of its drawing. PDF/X-1a spot colours keep their CMYK
|
||||
/// alternates.
|
||||
pub(crate) fn finish(mut self) -> Result<(Vec<u8>, Vec<String>), PdfError> {
|
||||
let mut bytes = self.pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?;
|
||||
let mut bytes = self.pdf.finish()?;
|
||||
if self.cmyk {
|
||||
cmyk_blending(&mut bytes, self.out.tagged);
|
||||
}
|
||||
@@ -1578,13 +1572,13 @@ impl Exporter<'_> {
|
||||
Embedding::Subset => {
|
||||
// A named instance of a variable font is embedded instanced (its own outlines and
|
||||
// widths, not the default instance's).
|
||||
let coords: Vec<(krilla::text::Tag, f32)> = face.variations().iter().map(|(t, v)| (krilla::text::Tag::new(t), *v)).collect();
|
||||
let font =
|
||||
krilla::text::Font::new_variable(face.file_data().to_vec().into(), face.face_index(), &coords).map(|f| (f, face.units_per_em()));
|
||||
if font.is_none() {
|
||||
self.warn(format!("the font “{name}” can't be embedded: its text is exported as outlines"));
|
||||
match write::font(face.file_data(), face.face_index(), Some(face.variations())) {
|
||||
Ok(font) => Some((font, face.units_per_em())),
|
||||
Err(error) => {
|
||||
self.warn(format!("the font “{name}” can't be embedded ({error}): its text is exported as outlines"));
|
||||
None
|
||||
}
|
||||
}
|
||||
font
|
||||
}
|
||||
Embedding::Whole => {
|
||||
self.warn(format!(
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::Read;
|
||||
|
||||
use craft_pdf::write::backend as krilla;
|
||||
use krilla::geom::PathBuilder;
|
||||
use krilla::graphic::Graphic;
|
||||
use krilla::surface::Surface;
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_pdf::write::backend as krilla;
|
||||
use image::imageops::FilterType;
|
||||
use image::{DynamicImage, ExtendedColorType, ImageDecoder, ImageFormat, ImageReader};
|
||||
use krilla::image::{BitsPerComponent, CustomImage, Image, ImageColorspace};
|
||||
|
||||
@@ -219,6 +219,12 @@ pub enum PdfError {
|
||||
PlaceholderOnly,
|
||||
}
|
||||
|
||||
impl From<craft_pdf::write::WriteError> for PdfError {
|
||||
fn from(error: craft_pdf::write::WriteError) -> Self {
|
||||
Self::Write(error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use craft_pdf::write::backend as krilla;
|
||||
use krilla::image::ImageColorspace;
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_color::cms::{self, Cms, CmykLut, Intent, Model, ProfileKind, ProofLut};
|
||||
|
||||
@@ -390,6 +390,10 @@ pub fn plan<'a>(doc: &'a Document, opts: &PrintOptions) -> Result<PrintPlan<'a>,
|
||||
|
||||
/// Print `doc` to a PDF as `opts` say.
|
||||
pub fn print(doc: &Document, opts: &PrintOptions) -> Result<PrintReport, PdfError> {
|
||||
craft_pdf::write::guard(|| print_inner(doc, opts))
|
||||
}
|
||||
|
||||
fn print_inner(doc: &Document, opts: &PrintOptions) -> Result<PrintReport, PdfError> {
|
||||
let plan = plan(doc, opts)?;
|
||||
let doc = &*plan.doc;
|
||||
let separations = opts.settings.output.mode == OutputMode::Separations;
|
||||
|
||||
@@ -734,3 +734,22 @@ fn opacity_mask_exports_as_luminosity_soft_mask() {
|
||||
assert!(text.contains("/SMask"), "soft mask in an ExtGState");
|
||||
assert!(text.contains("/Luminosity"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_populated_file_info_preserve_key_presence() {
|
||||
let mut d = doc(100.0, 100.0);
|
||||
d.title.clear();
|
||||
let s = uncompressed(&d);
|
||||
for key in ["/Title", "/Author", "/Keywords", "<dc:title>", "<dc:creator>", "<pdf:Keywords>"] {
|
||||
assert!(!s.contains(key), "unexpected empty metadata {key}");
|
||||
}
|
||||
d.title = "Synthetic title".into();
|
||||
d.metadata.author = " Test author ".into();
|
||||
d.metadata.keywords = vec!["test keyword".into()];
|
||||
let bytes = export(&d, &PdfOptions { created: Some(1_700_000_000), ..PdfOptions::uncompressed() }).unwrap();
|
||||
let s = String::from_utf8_lossy(&bytes);
|
||||
for key in ["/Title", "/Author", "/Keywords", "/CreationDate", "<dc:title>", "<dc:creator>", "<pdf:Keywords>"] {
|
||||
assert!(s.contains(key), "missing populated metadata {key}");
|
||||
}
|
||||
assert!(s.contains("Synthetic title") && s.contains("Test author") && s.contains("test keyword"));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! PDF text in installed fonts imports as live type (one point type object per line).
|
||||
|
||||
use craft_pdf::write::backend as krilla;
|
||||
use krilla::geom::{Point as KPoint, Transform};
|
||||
use krilla::page::PageSettings;
|
||||
use krilla::text::{Font, TextDirection};
|
||||
|
||||
@@ -1226,7 +1226,7 @@ impl Renderer {
|
||||
off.flush();
|
||||
let mut pm = vello_cpu::Pixmap::new(w, h);
|
||||
off.render(&mut pm, &mut self.resources);
|
||||
if !self.pixel_fx.get(&old).is_some_and(|e| e.made.pixels.as_slice() == pm.data_as_u8_slice()) {
|
||||
if self.pixel_fx.get(&old).is_none_or(|e| e.made.pixels.as_slice() != pm.data_as_u8_slice()) {
|
||||
return;
|
||||
}
|
||||
let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node)));
|
||||
|
||||
@@ -127,7 +127,7 @@ impl Settle {
|
||||
}
|
||||
let waited = now - self.since;
|
||||
// The clock went back (a new egui context): don't wait for it to catch up.
|
||||
if waited >= SETTLE_SECS || waited < 0.0 {
|
||||
if !waited.is_nan() && !(0.0..SETTLE_SECS).contains(&waited) {
|
||||
return true;
|
||||
}
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs_f64((SETTLE_SECS - waited).max(0.0)));
|
||||
@@ -173,5 +173,6 @@ mod tests {
|
||||
assert!(!s.settled(&ctx, 1, 2, 10.2), "a new revision starts it again");
|
||||
assert!(!s.settled(&ctx, 2, 2, 10.25), "so does another document");
|
||||
assert!(s.settled(&ctx, 2, 2, 1.0), "a clock that went back doesn't hold it up");
|
||||
assert!(!s.settled(&ctx, 2, 2, f64::NAN), "an unknown elapsed time is still unsettled");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Architecture
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; desktop printing uses craft-print; Windows 7 fallback retained) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (shared PDF writer; app output policy and existing reader retained; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; desktop printing uses craft-print; Windows 7 fallback retained) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
How VectorCraft is built today. The design rationale lives in the local, gitignored `plan/architecture.md`;
|
||||
this file describes what is committed. Day-to-day rules (clean-room, never crash, quality gates) are in
|
||||
@@ -59,6 +59,12 @@ reaches the status bar, and Quit waits for pending submissions. Windows 7 retain
|
||||
and the installed viewer's Print/PrintTo verb;
|
||||
its target excludes the shared backend because Windows.Data.Pdf is unavailable there.
|
||||
|
||||
PDF writing uses the optional `craft-pdf` writer at an immutable craft-libs revision. Shared
|
||||
configuration, metadata, font preparation and guarded serialization leave drawing, colour
|
||||
policy, layers, editing data and print composition in this app. The reader keeps its existing
|
||||
Hayro dependencies. `cargo xtask pdf-deps` verifies the shared writer and patched Krilla source;
|
||||
see [pdf-backend.md](pdf-backend.md).
|
||||
|
||||
## Agent control
|
||||
|
||||
- **Control channel:** JSON lines on a local TCP port (`--control 7979`), with real egui pointer and keyboard
|
||||
@@ -84,6 +90,7 @@ saves copies in the background. See [development.md](development.md#robustness-v
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Pinned PngSuite HTTPS files verified by SHA-256 before publication; bounded downloads replace archive extraction (#1729). Scores and hours unchanged. |
|
||||
| 2026-10-13 | minor | Bounded synthetic control input (#1709): counts and queued events validated before mutation; control-channel bursts yield between frames. Scores and hours unchanged. |
|
||||
| 2026-10-13 | minor | PDF writer setup and guarded finalization use craft-pdf; drawing and output policy remain app-owned. |
|
||||
| 2026-10-12 | minor | Desktop PDF spooler shared through craft-print; exact physical sizes, composed copies and Windows 7 fallback retained. Scores and remaining gaps unchanged. |
|
||||
| 2026-10-10 | minor | Added the `three-d` crate |
|
||||
| 2026-10-10 | major | First version, from the workspace, the layering table and the existing docs |
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,26 @@
|
||||
# PDF writer backend
|
||||
|
||||
PDF writing uses `craft-pdf` from `storytold/craft-libs`, pinned to
|
||||
`c3487320b2d1de657b860b3c8abb44e1f2b32bd8`. Its `write` feature supplies Krilla configuration,
|
||||
metadata construction, fallible font preparation and serialization, and native unwind guards.
|
||||
The dependency disables default features and enables only `write`.
|
||||
|
||||
VectorCraft owns document traversal, drawing, font selection and embedding policy, colour
|
||||
conversion, presets, page boxes, layers, editing data, postprocessing and print composition.
|
||||
The existing Hayro reader dependencies and editable import adapters keep their current sources.
|
||||
Desktop printer submission remains in `craft-print`.
|
||||
|
||||
The shared writer vendors Krilla 0.8.2 with an unwind-safe cleanup and poisoning patch.
|
||||
The root `[patch.crates-io]` selects that copy from the same immutable revision: Cargo does
|
||||
not inherit a dependency workspace's patches. A failed page or surface cleanup poisons the
|
||||
backend document, so serialization cannot return an incomplete PDF after unwinding.
|
||||
The shared crate's compile-time backend marker and `cargo xtask pdf-deps` (included in CI)
|
||||
check that the patched writer is used. Krilla retains its MIT OR Apache-2.0 licensing;
|
||||
its upstream provenance and authored patch are recorded in the shared repository.
|
||||
|
||||
Export and print construction, drawing and postprocessing run inside the shared native
|
||||
unwind guard. These guards do not catch WASM aborts or process-wide allocation failures.
|
||||
VectorCraft retains its finite-coordinate checks and the existing behavior that omits art
|
||||
outside the writer's supported coordinate range. Font preparation checks the shared
|
||||
512 MiB per-font copy limit and variation coordinates before allocation; failure preserves
|
||||
VectorCraft's reported outline fallback. The writer is not a peak-memory or runtime budget.
|
||||
+3
-2
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+5
-1
@@ -9,6 +9,7 @@ mod brands;
|
||||
mod bundle;
|
||||
mod ico;
|
||||
mod layers;
|
||||
mod pdf_deps;
|
||||
mod pngsuite;
|
||||
mod sha256;
|
||||
mod stats;
|
||||
@@ -24,9 +25,10 @@ usage: cargo xtask <command>
|
||||
commands:
|
||||
assets check that every icon/image/font/asset is attributed in ASSETS.md
|
||||
brands check that user-visible text (labels, params docs, MCP, docs, packaging) names no other vendor's products
|
||||
pdf-deps verify immutable shared PDF writer and patched Krilla sources
|
||||
layers enforce the crate dependency layering (plan/architecture.md §3)
|
||||
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates
|
||||
ci fmt --check, clippy -D warnings, test, assets, brands, layers, wasm (stops at first failure)
|
||||
ci fmt --check, clippy -D warnings, test, pdf-deps, assets, brands, layers, wasm (stops at first failure)
|
||||
corpus [--download] [--affinity]
|
||||
show where test corpora live; --download fetches PngSuite into corpus/pngsuite,
|
||||
--affinity the pinned public Affinity documents into corpus/affinity
|
||||
@@ -45,6 +47,7 @@ fn main() -> ExitCode {
|
||||
Some("assets") => assets::run(&root()),
|
||||
Some("brands") => brands::run(&root()),
|
||||
Some("layers") => cmd_layers(),
|
||||
Some("pdf-deps") => pdf_deps::run(&root()),
|
||||
Some("wasm") => cmd_wasm(),
|
||||
Some("ci") => cmd_ci(),
|
||||
Some("corpus") if rest.contains(&"--affinity") => affinity_corpus::fetch(),
|
||||
@@ -200,6 +203,7 @@ fn cmd_ci() -> Result<(), String> {
|
||||
run(c, "cargo test --workspace")
|
||||
}),
|
||||
),
|
||||
("pdf-deps", Box::new(|| pdf_deps::run(&root()))),
|
||||
("assets", Box::new(|| assets::run(&root()))),
|
||||
("brands", Box::new(|| brands::run(&root()))),
|
||||
("layers", Box::new(cmd_layers)),
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Verify that PDF writing uses one immutable shared crate and its patched writer backend.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
const REV: &str = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8";
|
||||
const PACKAGES: &[(&str, &str)] = &[("craft-pdf", "0.1.0"), ("krilla", "0.8.2")];
|
||||
|
||||
pub fn run(root: &Path) -> Result<(), String> {
|
||||
let output = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()))
|
||||
.current_dir(root)
|
||||
.args(["metadata", "--locked", "--format-version", "1"])
|
||||
.output()
|
||||
.map_err(|e| format!("cargo metadata: {e}"))?;
|
||||
if !output.status.success() {
|
||||
return Err(format!("cargo metadata: {}", String::from_utf8_lossy(&output.stderr)));
|
||||
}
|
||||
let metadata: Value = serde_json::from_slice(&output.stdout).map_err(|e| format!("cargo metadata: {e}"))?;
|
||||
validate(&metadata)?;
|
||||
println!("PDF writer: shared crate and patched backend resolve once to {REV}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate(metadata: &Value) -> Result<(), String> {
|
||||
let packages = metadata.get("packages").and_then(Value::as_array).ok_or("cargo metadata has no packages")?;
|
||||
let source = format!("git+https://github.com/storytold/craft-libs?rev={REV}#{REV}");
|
||||
for &(name, version) in PACKAGES {
|
||||
let mut found = packages.iter().filter(|p| p.get("name").and_then(Value::as_str) == Some(name));
|
||||
let package = found.next().ok_or_else(|| format!("{name}: missing resolved package"))?;
|
||||
if found.next().is_some() {
|
||||
return Err(format!("{name}: expected one resolved package"));
|
||||
}
|
||||
if package.get("version").and_then(Value::as_str) != Some(version) || package.get("source").and_then(Value::as_str) != Some(source.as_str()) {
|
||||
return Err(format!("{name}: expected {version} from {source}; got {package}"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn metadata() -> Value {
|
||||
let packages: Vec<_> = PACKAGES
|
||||
.iter()
|
||||
.map(|(name, version)| {
|
||||
json!({
|
||||
"name":name, "version":version,
|
||||
"source":format!("git+https://github.com/storytold/craft-libs?rev={REV}#{REV}")
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({"packages": packages})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_the_pinned_writer_without_changing_reader_sources() {
|
||||
let mut m = metadata();
|
||||
m["packages"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(json!({"name":"hayro-syntax", "version":"0.7.2", "source":"registry+https://github.com/rust-lang/crates.io-index"}));
|
||||
assert!(validate(&m).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_or_duplicate_writer_packages() {
|
||||
for index in 0..PACKAGES.len() {
|
||||
let mut m = metadata();
|
||||
m["packages"].as_array_mut().unwrap().remove(index);
|
||||
assert!(validate(&m).is_err());
|
||||
let mut m = metadata();
|
||||
let duplicate = m["packages"][index].clone();
|
||||
m["packages"].as_array_mut().unwrap().push(duplicate);
|
||||
assert!(validate(&m).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_registry_local_version_and_revision_drift() {
|
||||
for index in 0..PACKAGES.len() {
|
||||
for (field, value) in [
|
||||
("source", Value::Null),
|
||||
("source", json!("registry+https://github.com/rust-lang/crates.io-index")),
|
||||
("source", json!("git+https://github.com/storytold/craft-libs?branch=main#other")),
|
||||
("version", json!("0.99.0")),
|
||||
] {
|
||||
let mut m = metadata();
|
||||
m["packages"][index][field] = value;
|
||||
assert!(validate(&m).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user