M4.61: PDF import fidelity: opacity masks, isolate/knockout, pattern swatches, editable text, gradient meshes, stop opacity and Extend
Luminosity soft masks become opacity masks (alpha masks as a white copy of their art; backdrop colour gives Clip, an inverting transfer function Invert; the PDF export's backdrop and Difference covers read back as the options; a constant alpha mask is opacity). Transparency groups keep their /I and /K flags (read by walking the content as the interpreter does). Tiling patterns become pattern swatches (one per colour for uncoloured ones). Text becomes point type, one object per line with a run per font and paint, fonts named from the file (document.open textAs: text | outlines). Patch and triangle mesh shadings become gradient meshes; gradients drawn through their grey opacity mask get stop opacity back; shadings that don't extend clip the art. A rectangular clip around all its art (a form's box) is dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
66e92bbd4c
commit
a51083733e
|
|
@ -887,6 +887,14 @@ fn default_graphic_styles() -> Vec<GraphicStyle> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Make the next id allocated at least `next`: ids handed out outside the layer tree (such
|
||||
/// as an importer's opacity mask and pattern art), which [`Document::fix_next_id`] doesn't see.
|
||||
pub fn reserve_ids(&mut self, next: u64) {
|
||||
self.next_id = self.next_id.max(next);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Open Document",
|
||||
[],
|
||||
None,
|
||||
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard and layer each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK) → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
|
||||
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard and layer each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK) → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
|
||||
always,
|
||||
load::open
|
||||
),
|
||||
|
|
@ -655,6 +655,8 @@ mod tests_pdfcolor;
|
|||
#[cfg(test)]
|
||||
mod tests_pdfedit;
|
||||
#[cfg(test)]
|
||||
mod tests_pdffidelity;
|
||||
#[cfg(test)]
|
||||
mod tests_pdfimport;
|
||||
#[cfg(test)]
|
||||
mod tests_svg;
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@
|
|||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{ColorMode, Document};
|
||||
use vectorcraft_pdf::{Choice, CropTo, ImportOptions, PdfError};
|
||||
use vectorcraft_pdf::{Choice, CropTo, ImportOptions, PdfError, TextAs};
|
||||
|
||||
use super::super::*;
|
||||
use super::{err, source};
|
||||
|
||||
/// The `document.open` options besides the file. The PDF ones (pages, box, password) are ignored
|
||||
/// by other formats.
|
||||
/// The `document.open` options besides the file. The PDF ones (pages, box, password, text) are
|
||||
/// ignored by other formats.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct LoadOptions {
|
||||
/// 1-based pages to import, such as `"2-3, 5"`; `None` = every page.
|
||||
|
|
@ -22,11 +22,14 @@ pub struct LoadOptions {
|
|||
/// The colour mode the document opens in, its colours converted as Document Color Mode does
|
||||
/// (default: the file's; a PDF painted mostly in CMYK opens in CMYK).
|
||||
pub color_mode: Option<ColorMode>,
|
||||
/// What a PDF's text becomes.
|
||||
pub text_as: TextAs,
|
||||
}
|
||||
|
||||
impl LoadOptions {
|
||||
/// From command params: `pages` (`"2-3, 5"`, a page number or `"all"`), `page` (one page, when
|
||||
/// `pages` isn't given), `cropTo` (or `crop`), `password` and `colorMode` (`rgb` | `cmyk`).
|
||||
/// `pages` isn't given), `cropTo` (or `crop`), `password`, `colorMode` (`rgb` | `cmyk`) and
|
||||
/// `textAs` (`text` | `outlines`).
|
||||
pub fn from_params(cmd: &str, p: &Value) -> Result<Self> {
|
||||
let pages = match p.get("pages").filter(|v| !v.is_null()).or_else(|| p.get("page").filter(|v| !v.is_null())) {
|
||||
None => None,
|
||||
|
|
@ -35,15 +38,7 @@ impl LoadOptions {
|
|||
Some(Value::Number(n)) => Some(n.to_string()),
|
||||
Some(v) => return Err(bad(cmd, format!("pages must be a range such as \"2-3\" or a page number, not {v}"))),
|
||||
};
|
||||
let crop = match str_param(p, "cropTo").or_else(|| str_param(p, "crop")) {
|
||||
None => CropTo::default(),
|
||||
Some(c) => CropTo::ALL
|
||||
.iter()
|
||||
.zip(CropTo::IDS)
|
||||
.find(|(_, id)| id.eq_ignore_ascii_case(c))
|
||||
.map(|(c, _)| *c)
|
||||
.ok_or_else(|| bad(cmd, format!("cropTo must be one of {}", CropTo::IDS.join(", "))))?,
|
||||
};
|
||||
let crop = choice(cmd, "cropTo", str_param(p, "cropTo").or_else(|| str_param(p, "crop")))?;
|
||||
let password = str_param(p, "password").filter(|s| !s.is_empty()).map(str::to_string);
|
||||
let color_mode = match str_param(p, "colorMode").map(str::to_ascii_lowercase).as_deref() {
|
||||
None => None,
|
||||
|
|
@ -51,7 +46,8 @@ impl LoadOptions {
|
|||
Some("cmyk") => Some(ColorMode::Cmyk),
|
||||
Some(m) => return Err(bad(cmd, format!("colorMode must be rgb or cmyk, not `{m}`"))),
|
||||
};
|
||||
Ok(Self { pages, crop, password, color_mode })
|
||||
let text_as = choice(cmd, "textAs", str_param(p, "textAs"))?;
|
||||
Ok(Self { pages, crop, password, color_mode, text_as })
|
||||
}
|
||||
|
||||
/// Does the document read only part of the file, or read it differently (a page range, another
|
||||
|
|
@ -69,20 +65,31 @@ impl LoadOptions {
|
|||
Some(parse_range(range, count).map_err(|e| err(format!("pages: {e}")))?)
|
||||
}
|
||||
};
|
||||
Ok(ImportOptions { pages, crop: self.crop, password: self.password.clone(), ..Default::default() })
|
||||
Ok(ImportOptions { pages, crop: self.crop, password: self.password.clone(), text_as: self.text_as, ..Default::default() })
|
||||
}
|
||||
}
|
||||
|
||||
/// The option of `T` named `id` (any case), for param `key`; not given: the default.
|
||||
fn choice<T: Choice + Default>(cmd: &str, key: &str, id: Option<&str>) -> Result<T> {
|
||||
let Some(id) = id else { return Ok(T::default()) };
|
||||
T::ALL
|
||||
.iter()
|
||||
.zip(T::IDS)
|
||||
.find(|(_, i)| i.eq_ignore_ascii_case(id))
|
||||
.map(|(t, _)| *t)
|
||||
.ok_or_else(|| bad(cmd, format!("{key} must be one of {}", T::IDS.join(", "))))
|
||||
}
|
||||
|
||||
/// Import the pages `o` pick → the document and import notes.
|
||||
pub(super) fn import(bytes: &[u8], o: &LoadOptions) -> Result<(Document, Vec<String>)> {
|
||||
let r = vectorcraft_pdf::import_with_report(bytes, &o.import_options(bytes)?).map_err(err)?;
|
||||
Ok((r.document, r.warnings))
|
||||
}
|
||||
|
||||
/// Page `page` (0-based) of a PDF as a document of one artboard (its `o.crop` box) and the
|
||||
/// import notes.
|
||||
/// Page `page` (0-based) of a PDF as a document of one artboard (its `o.crop` box), its text as
|
||||
/// outlines (it looks as printed), and the import notes.
|
||||
pub fn page_document(bytes: &[u8], page: usize, o: &LoadOptions) -> Result<(Document, Vec<String>)> {
|
||||
let opts = ImportOptions { pages: Some(vec![page]), crop: o.crop, password: o.password.clone(), ..Default::default() };
|
||||
let opts = ImportOptions { pages: Some(vec![page]), crop: o.crop, password: o.password.clone(), text_as: TextAs::Outlines, ..Default::default() };
|
||||
vectorcraft_pdf::import_with_report(bytes, &opts).map(|r| (r.document, r.warnings)).map_err(err)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
//! `document.open` PDF option `textAs`.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_pdf::TextAs;
|
||||
use vectorcraft_testkit::pdf::{PdfPage, pdf};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn open(s: &mut Session, bytes: &[u8], extra: Value) -> crate::Result<Value> {
|
||||
let mut p = json!({"name": "art.pdf", "dataBase64": vectorcraft_format::base64_encode(bytes)});
|
||||
if let (Some(o), Value::Object(e)) = (p.as_object_mut(), extra) {
|
||||
o.extend(e);
|
||||
}
|
||||
s.execute("document.open", &p)
|
||||
}
|
||||
|
||||
fn kinds(s: &Session) -> Vec<&'static str> {
|
||||
let mut out = vec![];
|
||||
s.active().unwrap().doc.walk(|n| out.push(n.kind_label()));
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_opens_as_type_or_outlines() {
|
||||
let page = PdfPage {
|
||||
resources: "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >>".into(),
|
||||
..PdfPage::new(200.0, 100.0, "BT /F1 12 Tf 10 50 Td (Hello) Tj ET")
|
||||
};
|
||||
let bytes = pdf(&[page], None);
|
||||
let mut s = Session::new();
|
||||
open(&mut s, &bytes, json!({})).unwrap();
|
||||
assert!(kinds(&s).contains(&"Type"), "{:?}", kinds(&s));
|
||||
let r = open(&mut s, &bytes, json!({"textAs": "outlines"})).unwrap();
|
||||
assert!(!kinds(&s).contains(&"Type"));
|
||||
assert!(r["warnings"].to_string().contains("outlines"), "{r}");
|
||||
let e = open(&mut s, &bytes, json!({"textAs": "glyphs"})).unwrap_err().to_string();
|
||||
assert!(e.contains("textAs must be one of text, outlines"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_options_read_text() {
|
||||
let o = LoadOptions::from_params("x", &json!({"textAs": "Outlines"})).unwrap();
|
||||
assert_eq!(o.text_as, TextAs::Outlines);
|
||||
assert!(!o.is_partial());
|
||||
assert_eq!(LoadOptions::default().text_as, TextAs::Text);
|
||||
}
|
||||
|
|
@ -435,6 +435,76 @@ proptest! {
|
|||
}
|
||||
}
|
||||
|
||||
/// Hostile content over soft masks, isolated and knockout groups, tiling patterns, a patch mesh,
|
||||
/// an unextended shading and fonts: the import's own readings of the file.
|
||||
fn rich_resources_pdf(content: &str) -> Vec<u8> {
|
||||
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with};
|
||||
let x = first_extra(1);
|
||||
let stream = |dict: &str, data: &str| format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len());
|
||||
let extra = [
|
||||
stream(
|
||||
"/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /CS /DeviceRGB /I true /K true >>",
|
||||
"1 g 0 0 50 50 re f /X BMC 0 g 0 0 5 5 re f EMC",
|
||||
),
|
||||
stream(
|
||||
"/PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 1e-9 10] /XStep 0.001 /YStep 10 /Resources << >>",
|
||||
"0 0 5 5 re f /P0 scn 0 0 1 1 re f",
|
||||
),
|
||||
stream(
|
||||
"/ShadingType 6 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 255 0 255 0 1 0 1 0 1] /Filter /ASCIIHexDecode",
|
||||
"000A0A0A250A3F0A5A255A3F5A5A5A5A3F5A255A0A3F0A250AFF000000FF000000FFFFFFFF>",
|
||||
),
|
||||
];
|
||||
let (form, pat, mesh) = (x, x + 1, x + 2);
|
||||
let resources = format!(
|
||||
"/XObject << /X0 {form} 0 R >> /Pattern << /P0 {pat} 0 R >> /Shading << /Sh0 {mesh} 0 R /Sh1 << /ShadingType 3 /ColorSpace /DeviceGray /Coords [50 50 0 50 50 40] /Extend [false false] /Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> >> >> /ExtGState << /GS0 << /SMask << /Type /Mask /S /Luminosity /G {form} 0 R /BC [1] /TR << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> >> >> /GS1 << /SMask << /Type /Mask /S /Alpha /G {form} 0 R >> >> >> /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Odd-Name,Bold >> >> /ColorSpace << /CS0 [/Pattern /DeviceRGB] >>"
|
||||
);
|
||||
let extra: Vec<&str> = extra.iter().map(String::as_str).collect();
|
||||
pdf_with(&[PdfPage { resources, ..PdfPage::new(100.0, 100.0, content) }], &extra, None)
|
||||
}
|
||||
|
||||
fn arb_rich_op() -> impl Strategy<Value = String> {
|
||||
prop_oneof![
|
||||
arb_pdf_op(),
|
||||
prop::sample::select(vec![
|
||||
"/Span << /MCID 3 >> BDC",
|
||||
"/X BMC",
|
||||
"EMC",
|
||||
"/X0 Do",
|
||||
"/GS0 gs",
|
||||
"/GS1 gs",
|
||||
"/CS0 cs 1 0 0 /P0 scn",
|
||||
"/Pattern cs /P0 scn",
|
||||
"/Sh0 sh",
|
||||
"/Sh1 sh",
|
||||
"BT /F1 12 Tf 10 10 Td (Hi there) Tj [(a) -900 (b)] TJ ET",
|
||||
"7 Tr",
|
||||
"q 10 10 50 50 re W n",
|
||||
"Q",
|
||||
"0 0 100 100 re f",
|
||||
"10 10 20 20 re B",
|
||||
])
|
||||
.prop_map(String::from),
|
||||
]
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
#[test]
|
||||
fn pdf_masks_patterns_and_text_never_panic(
|
||||
ops in prop::collection::vec(arb_rich_op(), 0..40),
|
||||
outlines in any::<bool>(),
|
||||
) {
|
||||
let bytes = rich_resources_pdf(&ops.join("\n"));
|
||||
let opts = vectorcraft_pdf::ImportOptions {
|
||||
text_as: if outlines { vectorcraft_pdf::TextAs::Outlines } else { vectorcraft_pdf::TextAs::Text },
|
||||
..Default::default()
|
||||
};
|
||||
survive(&ops.join(" "), || vectorcraft_pdf::import_with_report(&bytes, &opts).ok().map(|r| r.document))?;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- library files ----------
|
||||
|
||||
/// The `data` of a library file `cmd` writes for the rich document.
|
||||
|
|
|
|||
|
|
@ -4,26 +4,34 @@ use std::collections::HashMap;
|
|||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use hayro_interpret::font::Glyph;
|
||||
use hayro_interpret::pattern::Pattern;
|
||||
use hayro_interpret::shading::ShadingType;
|
||||
use hayro_interpret::hayro_cmap::BfString;
|
||||
use hayro_interpret::pattern::{Pattern, TilingPattern};
|
||||
use hayro_interpret::{
|
||||
ClipPath, Context, Device, GlyphDrawMode, Image, ImageData, InterpreterCache, InterpreterSettings, InterpreterWarning, LumaData, PathDrawMode,
|
||||
SoftMask, StrokeProps, interpret_page,
|
||||
CacheKey, ClipPath, Context, Device, GlyphDrawMode, Image, ImageData, InterpreterCache, InterpreterSettings, InterpreterWarning, LumaData,
|
||||
MaskType, PathDrawMode, SoftMask, StrokeProps, interpret_page,
|
||||
};
|
||||
use hayro_syntax::object::Name;
|
||||
use kurbo::{Affine, BezPath, Point, Rect, Shape, Vec2};
|
||||
use vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
use kurbo::{Affine, BezPath, Rect, Shape};
|
||||
use vectorcraft_color::{BlendMode, Color, Paint, Swatch};
|
||||
use vectorcraft_doc::{
|
||||
Appearance, AppearanceItem, Artboard, ColorMode, Dash, Document, FillLayer, ImageBlob, ImageObject, LayerColor, LineCap, LineJoin, Node, NodeId,
|
||||
NodeKind, StrokeLayer,
|
||||
Appearance, AppearanceItem, Artboard, ColorMode, Dash, Document, FillLayer, ImageBlob, ImageObject, Knockout, LayerColor, LineCap, LineJoin,
|
||||
Node, NodeId, NodeKind, OpacityMask, PatternDef, StrokeLayer,
|
||||
};
|
||||
use vectorcraft_geom::{FillRule, PathData};
|
||||
|
||||
use crate::import_color::{Colors, Native};
|
||||
use crate::{CropTo, ImportOptions, ImportReport, PdfError};
|
||||
use crate::import_mask::{MaskSpec, contains, is_rectangle, luminance, mask_spec};
|
||||
use crate::import_scan::{Ocgs, Scan, scan_page};
|
||||
use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, mesh_shading, shading_gradient};
|
||||
use crate::import_text::{Families, Look, Placement, TextLine};
|
||||
use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs};
|
||||
|
||||
/// The name of the paths text imports as.
|
||||
const TEXT_OUTLINES: &str = "<Text Outlines>";
|
||||
/// The deepest soft masks, patterns and type 3 glyphs are read within each other.
|
||||
const MAX_NESTED: u32 = 8;
|
||||
/// A tiling pattern read this many times with the same art is taken to always draw it.
|
||||
const PATTERN_REUSE: u32 = 16;
|
||||
|
||||
/// Import a PDF (or PDF-compatible `.ai`) with default options.
|
||||
pub fn import(bytes: &[u8]) -> Result<Document, PdfError> {
|
||||
|
|
@ -35,6 +43,7 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
|||
let pdf = crate::pages::open(bytes, opts.password.as_deref())?;
|
||||
let pages = pdf.pages();
|
||||
let picked = crate::pages::picked(opts, pages.len())?;
|
||||
let ocgs = Ocgs::read(&pdf);
|
||||
|
||||
let sink: Arc<Mutex<Vec<String>>> = Arc::default();
|
||||
let sink2 = sink.clone();
|
||||
|
|
@ -58,8 +67,9 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
|||
doc.title = "Imported PDF".into();
|
||||
doc.artboards.clear();
|
||||
doc.layers.clear();
|
||||
let taken = doc.swatches_iter().map(|s| s.name.clone()).chain(doc.swatch_groups.iter().map(|g| g.name.clone())).collect();
|
||||
let mut b = Builder::new(doc.peek_next_id(), Colors::new(&pdf, taken));
|
||||
let taken: Vec<String> = doc.swatches_iter().map(|s| s.name.clone()).chain(doc.swatch_groups.iter().map(|g| g.name.clone())).collect();
|
||||
let mut b = Builder::new(doc.peek_next_id(), Colors::new(&pdf, taken.clone()), opts.text_as);
|
||||
b.taken = taken;
|
||||
let cache = InterpreterCache::new();
|
||||
let mut x = 0.0;
|
||||
// Every page only a placeholder (text) over private data: the file's art isn't in its PDF part.
|
||||
|
|
@ -72,7 +82,8 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
|||
let xf = Affine::translate((x - frame.x0, -frame.y0)) * init;
|
||||
let mut ctx = Context::new(xf, ab, &cache, pdf.xref(), settings.clone());
|
||||
b.page = ab;
|
||||
b.begin_page();
|
||||
let scan = scan_page(page, &ocgs, &mut b.fonts);
|
||||
b.begin_page(scan);
|
||||
interpret_page(page, &mut ctx, &mut b);
|
||||
let children = b.end_page();
|
||||
placeholder &= crate::pages::has_private_data(page) && only_text(&children);
|
||||
|
|
@ -109,10 +120,25 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
|||
(doc.swatches, doc.swatch_groups) = vectorcraft_color::default_swatches(ColorMode::Cmyk.model());
|
||||
}
|
||||
doc.swatches.extend(b.colors.swatches());
|
||||
// Tiling patterns are pattern swatches.
|
||||
for p in b.patterns.drain(..) {
|
||||
doc.swatches.push(Swatch {
|
||||
name: p.name.clone(),
|
||||
paint: Paint::Pattern { pattern: p.name.clone(), xf: Affine::IDENTITY },
|
||||
global: false,
|
||||
spot: false,
|
||||
});
|
||||
doc.patterns.push(p);
|
||||
}
|
||||
if b.colors.mixed {
|
||||
b.warn("colours mixing several inks (DeviceN) were imported as RGB");
|
||||
}
|
||||
if !b.missing_fonts.is_empty() {
|
||||
let list = b.missing_fonts.join(", ");
|
||||
b.warn(&format!("fonts that aren't available show in the fallback font until they are: {list}"));
|
||||
}
|
||||
doc.fix_next_id();
|
||||
doc.reserve_ids(b.next);
|
||||
|
||||
let mut warnings = b.warnings;
|
||||
if let Ok(v) = sink.lock() {
|
||||
|
|
@ -134,6 +160,10 @@ fn only_text(nodes: &[Arc<Node>]) -> bool {
|
|||
*text = true;
|
||||
true
|
||||
}
|
||||
NodeKind::Text(_) => {
|
||||
*text = true;
|
||||
true
|
||||
}
|
||||
NodeKind::Group { children, .. } => walk(children, text),
|
||||
_ => false,
|
||||
})
|
||||
|
|
@ -154,7 +184,9 @@ enum FrameKind {
|
|||
Group {
|
||||
opacity: f32,
|
||||
blend: BlendMode,
|
||||
masked: bool,
|
||||
mask: Option<Arc<MaskSpec>>,
|
||||
isolate: bool,
|
||||
knockout: bool,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -163,6 +195,12 @@ struct Frame {
|
|||
children: Vec<Arc<Node>>,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
fn new(kind: FrameKind) -> Self {
|
||||
Self { kind, children: vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
/// Glyphs drawn consecutively with the same paint, merged into one path.
|
||||
struct GlyphRun {
|
||||
path: BezPath,
|
||||
|
|
@ -172,6 +210,19 @@ struct GlyphRun {
|
|||
scale: f64,
|
||||
}
|
||||
|
||||
/// How often a tiling pattern was read, and the art (as its paints) each reading drew with the
|
||||
/// swatch made for it.
|
||||
type PatternReadings = (u32, Vec<(Vec<Paint>, String)>);
|
||||
|
||||
/// Builder state a nested interpretation (soft mask, pattern cell) starts afresh and gives back.
|
||||
struct Saved {
|
||||
stack: Vec<Frame>,
|
||||
blend: BlendMode,
|
||||
mask: Option<Arc<MaskSpec>>,
|
||||
page: Rect,
|
||||
pending: bool,
|
||||
}
|
||||
|
||||
struct Builder<'p> {
|
||||
colors: Colors<'p>,
|
||||
next: u64,
|
||||
|
|
@ -179,9 +230,31 @@ struct Builder<'p> {
|
|||
blend: BlendMode,
|
||||
page: Rect,
|
||||
glyphs: Option<GlyphRun>,
|
||||
text: Option<TextLine>,
|
||||
text_as: TextAs,
|
||||
images: HashMap<String, ImageBlob>,
|
||||
image_keys: HashMap<u128, (String, u32, u32)>,
|
||||
warnings: Vec<String>,
|
||||
/// Fonts by cache key → base font name (from [`scan_page`]).
|
||||
fonts: HashMap<u128, String>,
|
||||
font_names: HashMap<u128, (String, String)>,
|
||||
families: Option<Families>,
|
||||
missing_fonts: Vec<String>,
|
||||
/// The soft mask of the graphics state, the art drawn through it so far, and the masks read.
|
||||
mask: Option<Arc<MaskSpec>>,
|
||||
masked: Vec<Arc<Node>>,
|
||||
masks: HashMap<u128, Arc<MaskSpec>>,
|
||||
/// Pattern swatches made, and per (pattern, stroke) the art signatures read and their names.
|
||||
patterns: Vec<PatternDef>,
|
||||
pattern_keys: HashMap<(u128, bool), PatternReadings>,
|
||||
/// Swatch names in use.
|
||||
taken: Vec<String>,
|
||||
/// The page as walked by [`scan_page`], and how far the interpreter is through its groups.
|
||||
scan: Scan,
|
||||
group_at: usize,
|
||||
/// A transparency group was just pushed: a form's (whose flags come next) or an image's.
|
||||
pending: bool,
|
||||
nested: u32,
|
||||
}
|
||||
|
||||
fn blend(b: hayro_interpret::BlendMode) -> BlendMode {
|
||||
|
|
@ -237,12 +310,26 @@ fn stroke_layer(paint: Paint, opacity: f32, p: &StrokeProps, scale: f64) -> Stro
|
|||
st
|
||||
}
|
||||
|
||||
fn round3(v: f32) -> f32 {
|
||||
pub(crate) fn round3(v: f32) -> f32 {
|
||||
(v * 1000.0).round() / 1000.0
|
||||
}
|
||||
|
||||
/// The paints of `nodes`' leaves, in order: what tells two readings of a pattern cell apart.
|
||||
fn paints(nodes: &[Arc<Node>]) -> Vec<Paint> {
|
||||
let mut out = vec![];
|
||||
for n in nodes {
|
||||
n.walk(&mut |c| out.extend(c.appearance.items.iter().map(|i| i.paint().clone())));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Visual bounds of `nodes`; `None` when one has none.
|
||||
fn bounds(nodes: &[Arc<Node>]) -> Option<Rect> {
|
||||
nodes.iter().try_fold(None, |acc, n| n.visual_bounds().map(|b| Some(acc.map_or(b, |a: Rect| a.union(b)))))?
|
||||
}
|
||||
|
||||
impl<'p> Builder<'p> {
|
||||
fn new(next: u64, colors: Colors<'p>) -> Self {
|
||||
fn new(next: u64, colors: Colors<'p>, text_as: TextAs) -> Self {
|
||||
Self {
|
||||
colors,
|
||||
next,
|
||||
|
|
@ -250,9 +337,25 @@ impl<'p> Builder<'p> {
|
|||
blend: BlendMode::Normal,
|
||||
page: Rect::ZERO,
|
||||
glyphs: None,
|
||||
text: None,
|
||||
text_as,
|
||||
images: HashMap::new(),
|
||||
image_keys: HashMap::new(),
|
||||
warnings: vec![],
|
||||
fonts: HashMap::new(),
|
||||
font_names: HashMap::new(),
|
||||
families: None,
|
||||
missing_fonts: vec![],
|
||||
mask: None,
|
||||
masked: vec![],
|
||||
masks: HashMap::new(),
|
||||
patterns: vec![],
|
||||
pattern_keys: HashMap::new(),
|
||||
taken: vec![],
|
||||
scan: Scan::default(),
|
||||
group_at: 0,
|
||||
pending: false,
|
||||
nested: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -262,35 +365,126 @@ impl<'p> Builder<'p> {
|
|||
id
|
||||
}
|
||||
|
||||
/// A deep copy of `node` with fresh ids.
|
||||
fn reid(&mut self, node: &Node) -> Node {
|
||||
let mut n = node.clone();
|
||||
n.id = self.id();
|
||||
if let Some(ch) = n.children_mut() {
|
||||
let old = std::mem::take(ch);
|
||||
*ch = old.iter().map(|c| Arc::new(self.reid(c))).collect();
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// `m` with fresh ids for its art: a mask read once can mask several objects.
|
||||
fn fresh_mask(&mut self, mut m: OpacityMask) -> OpacityMask {
|
||||
m.art = Arc::new(self.reid(&m.art));
|
||||
m
|
||||
}
|
||||
|
||||
fn warn(&mut self, w: &str) {
|
||||
if !self.warnings.iter().any(|x| x == w) {
|
||||
self.warnings.push(w.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn begin_page(&mut self) {
|
||||
self.stack = vec![Frame { kind: FrameKind::Root, children: vec![] }];
|
||||
fn begin_page(&mut self, scan: Scan) {
|
||||
self.stack = vec![Frame::new(FrameKind::Root)];
|
||||
self.blend = BlendMode::Normal;
|
||||
self.glyphs = None;
|
||||
self.text = None;
|
||||
self.mask = None;
|
||||
self.masked.clear();
|
||||
self.pending = false;
|
||||
(self.scan, self.group_at) = (scan, 0);
|
||||
}
|
||||
|
||||
/// The page's art.
|
||||
fn end_page(&mut self) -> Vec<Arc<Node>> {
|
||||
self.flush_glyphs();
|
||||
self.close_frames()
|
||||
}
|
||||
|
||||
/// Finish the open runs and frames → the root frame's children.
|
||||
fn close_frames(&mut self) -> Vec<Arc<Node>> {
|
||||
self.flush();
|
||||
while self.stack.len() > 1 {
|
||||
self.pop_frame();
|
||||
}
|
||||
self.stack.pop().map(|f| f.children).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Add `n` to the open frame.
|
||||
fn emit(&mut self, n: Arc<Node>) {
|
||||
if let Some(f) = self.stack.last_mut() {
|
||||
f.children.push(n);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_node(&mut self, mut n: Node) {
|
||||
if self.blend != BlendMode::Normal && !n.is_container() {
|
||||
n.blend = self.blend;
|
||||
}
|
||||
if let Some(f) = self.stack.last_mut() {
|
||||
f.children.push(Arc::new(n));
|
||||
if self.mask.is_some() {
|
||||
self.masked.push(Arc::new(n));
|
||||
} else {
|
||||
self.emit(Arc::new(n));
|
||||
}
|
||||
}
|
||||
|
||||
/// Finish the open glyph runs and the art drawn through the current soft mask.
|
||||
fn flush(&mut self) {
|
||||
self.flush_glyphs();
|
||||
self.flush_text();
|
||||
self.flush_masked();
|
||||
}
|
||||
|
||||
/// The art drawn through the graphics state's soft mask so far, as one masked object.
|
||||
fn flush_masked(&mut self) {
|
||||
if self.masked.is_empty() {
|
||||
return;
|
||||
}
|
||||
let art = std::mem::take(&mut self.masked);
|
||||
let Some(spec) = self.mask.clone() else {
|
||||
art.into_iter().for_each(|n| self.emit(n));
|
||||
return;
|
||||
};
|
||||
match spec.as_ref() {
|
||||
// Each object is drawn through the mask on its own.
|
||||
MaskSpec::Opacity(a) => {
|
||||
for mut n in art {
|
||||
Arc::make_mut(&mut n).opacity *= a;
|
||||
self.emit(n);
|
||||
}
|
||||
}
|
||||
MaskSpec::Mask(m) => {
|
||||
let m = self.fresh_mask(m.clone());
|
||||
let node = match <[Arc<Node>; 1]>::try_from(art) {
|
||||
Ok([one]) if one.mask.is_none() => {
|
||||
fold_stop_opacity(&one, &m).unwrap_or_else(|| Node { mask: Some(Box::new(m)), ..Arc::unwrap_or_clone(one) })
|
||||
}
|
||||
Ok([one]) => {
|
||||
let mut g = Node::group(self.id(), vec![one]);
|
||||
g.mask = Some(Box::new(m));
|
||||
g
|
||||
}
|
||||
Err(all) => {
|
||||
let mut g = Node::group(self.id(), all);
|
||||
g.mask = Some(Box::new(m));
|
||||
g
|
||||
}
|
||||
};
|
||||
self.emit(Arc::new(node));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A clip path node.
|
||||
fn clip_node(&mut self, path: &BezPath, rule: FillRule) -> Node {
|
||||
let mut clip = Node::new(self.id(), NodeKind::Path { path: PathData::from_bezpath(path), rule, live: None, clipping: true, guide: false });
|
||||
clip.name = Some("<Clipping Path>".into());
|
||||
clip
|
||||
}
|
||||
|
||||
fn pop_frame(&mut self) {
|
||||
if self.stack.len() <= 1 {
|
||||
return;
|
||||
|
|
@ -299,48 +493,70 @@ impl<'p> Builder<'p> {
|
|||
let node = match f.kind {
|
||||
FrameKind::Root => None,
|
||||
FrameKind::Skip => {
|
||||
if let Some(p) = self.stack.last_mut() {
|
||||
p.children.extend(f.children);
|
||||
}
|
||||
f.children.into_iter().for_each(|c| self.emit(c));
|
||||
None
|
||||
}
|
||||
FrameKind::Clip(clip) => {
|
||||
let noop = clip.path_data().map(|p| p.to_bezpath()).filter(is_rectangle).map(|p| p.bounding_box().inflate(0.01, 0.01));
|
||||
if f.children.is_empty() {
|
||||
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));
|
||||
None
|
||||
} else {
|
||||
let mut ch = vec![Arc::new(*clip)];
|
||||
ch.extend(f.children);
|
||||
Some(Node::new(self.id(), NodeKind::Group { children: ch, clip: true }))
|
||||
}
|
||||
}
|
||||
FrameKind::Group { opacity, blend, masked } => {
|
||||
if f.children.is_empty() {
|
||||
FrameKind::Group { opacity, blend, mask, isolate, knockout } => {
|
||||
let children = f.children;
|
||||
// A constant alpha mask is opacity (of a group that isn't isolated).
|
||||
let (opacity, mask) = match mask.as_deref() {
|
||||
Some(MaskSpec::Opacity(a)) => (opacity * a, None),
|
||||
Some(MaskSpec::Mask(m)) => (opacity, Some(m.clone())),
|
||||
None => (opacity, None),
|
||||
};
|
||||
// Isolation only shows with blending inside; knockout with several objects.
|
||||
let isolate = isolate && Node::children_blend(&children, &mut |c| c.blends_through());
|
||||
let knockout = knockout && children.len() > 1;
|
||||
let plain = opacity >= 0.999 && blend == BlendMode::Normal && mask.is_none() && !isolate && !knockout;
|
||||
if children.is_empty() {
|
||||
None
|
||||
} else if opacity >= 0.999 && blend == BlendMode::Normal && !masked {
|
||||
if let Some(p) = self.stack.last_mut() {
|
||||
p.children.extend(f.children);
|
||||
}
|
||||
} else if plain {
|
||||
children.into_iter().for_each(|c| self.emit(c));
|
||||
None
|
||||
} else if f.children.len() == 1 && !f.children[0].is_container() && !masked {
|
||||
// A single object in a group: fold opacity/blend into the object itself.
|
||||
let mut only = (*f.children[0]).clone();
|
||||
} else if let [only] = children.as_slice()
|
||||
&& !only.is_container()
|
||||
&& !isolate
|
||||
&& !knockout
|
||||
&& (mask.is_none() || only.mask.is_none())
|
||||
{
|
||||
// A single object in a group: fold opacity, blend and mask into the object.
|
||||
let mut only = (**only).clone();
|
||||
only.opacity *= opacity;
|
||||
if blend != BlendMode::Normal {
|
||||
only.blend = blend;
|
||||
}
|
||||
if let Some(m) = mask {
|
||||
let m = self.fresh_mask(m);
|
||||
only = fold_stop_opacity(&only, &m).unwrap_or(Node { mask: Some(Box::new(m)), ..only });
|
||||
}
|
||||
Some(only)
|
||||
} else {
|
||||
let mut g = Node::group(self.id(), f.children);
|
||||
g.opacity = opacity;
|
||||
g.blend = blend;
|
||||
let mut g = Node::group(self.id(), children);
|
||||
(g.opacity, g.blend, g.isolate) = (opacity, blend, isolate);
|
||||
if knockout {
|
||||
g.knockout = Knockout::On;
|
||||
}
|
||||
g.mask = mask.map(|m| Box::new(self.fresh_mask(m)));
|
||||
Some(g)
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(n) = node
|
||||
&& let Some(p) = self.stack.last_mut()
|
||||
{
|
||||
p.children.push(Arc::new(n));
|
||||
if let Some(n) = node {
|
||||
self.emit(Arc::new(n));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -364,9 +580,15 @@ impl<'p> Builder<'p> {
|
|||
self.push_node(n);
|
||||
}
|
||||
|
||||
/// Convert a hayro paint to a VectorCraft paint and opacity. `bbox` is the painted area in
|
||||
/// document coordinates (for patterns).
|
||||
fn paint(&mut self, p: &hayro_interpret::Paint<'_>) -> (Paint, f32) {
|
||||
fn flush_text(&mut self) {
|
||||
let Some((t, opacity)) = self.text.take().and_then(TextLine::finish) else { return };
|
||||
let mut n = Node::new(self.id(), NodeKind::Text(Box::new(t)));
|
||||
n.opacity = opacity;
|
||||
self.push_node(n);
|
||||
}
|
||||
|
||||
/// Convert a hayro paint to a VectorCraft paint and opacity (`stroke`: for a stroke).
|
||||
fn paint(&mut self, p: &hayro_interpret::Paint<'_>, stroke: bool) -> (Paint, f32) {
|
||||
match p {
|
||||
hayro_interpret::Paint::Color(c) => {
|
||||
let [r, g, b, a] = c.to_rgba().components();
|
||||
|
|
@ -375,20 +597,107 @@ impl<'p> Builder<'p> {
|
|||
}
|
||||
hayro_interpret::Paint::Pattern(pat) => match pat.as_ref() {
|
||||
Pattern::Shading(sp) => match shading_gradient(sp, &mut self.colors) {
|
||||
Some(g) => (Paint::Gradient(Box::new(g)), sp.opacity),
|
||||
Some((g, _)) => (Paint::Gradient(Box::new(g)), sp.opacity),
|
||||
None => {
|
||||
self.warn("mesh/function shadings are imported as a flat colour");
|
||||
self.warn("function-based shadings (and very large meshes) are imported as a flat colour");
|
||||
(Paint::solid(Color::rgb(0.5, 0.5, 0.5)), sp.opacity)
|
||||
}
|
||||
},
|
||||
Pattern::Tiling(_) => {
|
||||
self.warn("tiling patterns are imported as a flat grey");
|
||||
(Paint::solid(Color::rgb(0.5, 0.5, 0.5)), 1.0)
|
||||
}
|
||||
Pattern::Tiling(t) => match self.tiling(t, stroke) {
|
||||
Some(paint) => (paint, 1.0),
|
||||
None => {
|
||||
self.warn("tiling patterns nested too deeply are imported as a flat grey");
|
||||
(Paint::solid(Color::rgb(0.5, 0.5, 0.5)), 1.0)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a soft mask, pattern cell or type 3 glyph (`read`) on its own: its art, in the
|
||||
/// coordinates `page` has. `None` when nested too deeply.
|
||||
fn nested(&mut self, page: Rect, read: impl FnOnce(&mut Self)) -> Option<Vec<Arc<Node>>> {
|
||||
if self.nested >= MAX_NESTED {
|
||||
return None;
|
||||
}
|
||||
self.flush();
|
||||
let saved = Saved {
|
||||
stack: std::mem::replace(&mut self.stack, vec![Frame::new(FrameKind::Root)]),
|
||||
blend: std::mem::replace(&mut self.blend, BlendMode::Normal),
|
||||
mask: self.mask.take(),
|
||||
page: std::mem::replace(&mut self.page, page),
|
||||
pending: std::mem::take(&mut self.pending),
|
||||
};
|
||||
self.nested += 1;
|
||||
read(self);
|
||||
let art = self.close_frames();
|
||||
self.nested -= 1;
|
||||
(self.stack, self.blend, self.mask, self.page, self.pending) = (saved.stack, saved.blend, saved.mask, saved.page, saved.pending);
|
||||
Some(art)
|
||||
}
|
||||
|
||||
/// What soft mask `m` does (read once per mask and placement).
|
||||
fn soft_mask(&mut self, m: &SoftMask<'_>) -> Arc<MaskSpec> {
|
||||
let key = m.cache_key();
|
||||
if let Some(s) = self.masks.get(&key) {
|
||||
return s.clone();
|
||||
}
|
||||
let page = self.page;
|
||||
let spec = match self.nested(page, |b| m.interpret(b)) {
|
||||
Some(art) => {
|
||||
let [r, g, b, _] = m.background_color().to_rgba().components();
|
||||
let backdrop = luminance(&Color::rgb(r, g, b));
|
||||
let inverted = m.transfer_function().is_some_and(|tf| tf.apply(0.0) > tf.apply(1.0));
|
||||
let id = self.id();
|
||||
mask_spec(art, m.mask_type() == MaskType::Alpha, backdrop, inverted, page, |all| Node::group(id, all))
|
||||
}
|
||||
None => {
|
||||
self.warn("soft masks nested too deeply were left out (content drawn unmasked)");
|
||||
MaskSpec::Opacity(1.0)
|
||||
}
|
||||
};
|
||||
let spec = Arc::new(spec);
|
||||
self.masks.insert(key, spec.clone());
|
||||
spec
|
||||
}
|
||||
|
||||
/// Tiling pattern `t` as a pattern swatch's paint (the swatch made the first time).
|
||||
fn tiling(&mut self, t: &TilingPattern<'_>, stroke: bool) -> Option<Paint> {
|
||||
let key = (t.cache_key(), stroke);
|
||||
let tile = Rect::from_origin_size(t.bbox.origin(), (t.x_step.abs().max(1e-3) as f64, t.y_step.abs().max(1e-3) as f64));
|
||||
let xf = t.matrix * Affine::translate(tile.origin().to_vec2());
|
||||
// An uncoloured pattern draws in the current colour: read it again unless it always
|
||||
// drew the same.
|
||||
if let Some((n, seen)) = self.pattern_keys.get(&key)
|
||||
&& *n >= PATTERN_REUSE
|
||||
&& let [(_, name)] = seen.as_slice()
|
||||
{
|
||||
return Some(Paint::Pattern { pattern: name.clone(), xf });
|
||||
}
|
||||
let art = self.nested(Rect::ZERO, |b| {
|
||||
// A cell whose content can't be decoded is empty.
|
||||
let _ = t.interpret(b, Affine::IDENTITY, stroke);
|
||||
})?;
|
||||
let sig = paints(&art);
|
||||
let entry = self.pattern_keys.entry(key).or_default();
|
||||
entry.0 += 1;
|
||||
let name = match entry.1.iter().find(|(s, _)| *s == sig) {
|
||||
Some((_, name)) => name.clone(),
|
||||
None => {
|
||||
let name = (self.patterns.len() + 1..).map(|i| format!("Pattern {i}")).find(|n| !self.taken.contains(n)).unwrap_or_default();
|
||||
self.taken.push(name.clone());
|
||||
let mut def = PatternDef::new(&name, art);
|
||||
def.tile = tile;
|
||||
self.patterns.push(def);
|
||||
if let Some(e) = self.pattern_keys.get_mut(&key) {
|
||||
e.1.push((sig, name.clone()));
|
||||
}
|
||||
name
|
||||
}
|
||||
};
|
||||
Some(Paint::Pattern { pattern: name, xf })
|
||||
}
|
||||
|
||||
fn add_image(&mut self, key: u128, make: impl FnOnce() -> Option<(ImageBlob, u32, u32)>, xf: Affine) {
|
||||
let (k, w, h) = if let Some(v) = self.image_keys.get(&key).cloned() {
|
||||
v
|
||||
|
|
@ -406,70 +715,93 @@ impl<'p> Builder<'p> {
|
|||
let id = self.id();
|
||||
self.push_node(Node::new(id, NodeKind::Image(ImageObject { key: k, width: w, height: h, xf, link: None, placement: Default::default() })));
|
||||
}
|
||||
}
|
||||
|
||||
/// Axial/radial shading → gradient paint (stops sampled from the shading function, in the
|
||||
/// shading's own colour model).
|
||||
fn shading_gradient(sp: &hayro_interpret::pattern::ShadingPattern, colors: &mut Colors<'_>) -> Option<GradientPaint> {
|
||||
let ShadingType::RadialAxial { coords, domain, function, axial, .. } = sp.shading.shading_type.as_ref() else {
|
||||
return None;
|
||||
};
|
||||
let m = sp.matrix;
|
||||
let cs = &sp.shading.color_space;
|
||||
let at = |t: f32| function.eval(&smallvec::smallvec![domain[0] + (domain[1] - domain[0]) * t]);
|
||||
let space = colors.shading_space(cs, at(0.0)?.len());
|
||||
// RGB to decide which samples are stops; the stops keep the shading's model.
|
||||
let sample = |t: f32| -> Option<(Color, f32)> {
|
||||
let [r, g, b, a] = cs.to_rgba(&at(t)?, 1.0, false).components();
|
||||
Some((Color::rgb(round3(r), round3(g), round3(b)), a))
|
||||
};
|
||||
// Sample densely, then drop samples that linear interpolation reproduces.
|
||||
const N: usize = 64;
|
||||
let mut pts: Vec<(f32, [f32; 3])> = Vec::with_capacity(N + 1);
|
||||
for i in 0..=N {
|
||||
let t = i as f32 / N as f32;
|
||||
let (c, _) = sample(t)?;
|
||||
pts.push((t, c.to_rgb()));
|
||||
}
|
||||
let mut keep = vec![0usize];
|
||||
for i in 1..N {
|
||||
let a = pts[keep.last().copied().unwrap_or(0)];
|
||||
let b = pts[i + 1];
|
||||
let u = (pts[i].0 - a.0) / (b.0 - a.0).max(1e-6);
|
||||
let off = (0..3).map(|k| (a.1[k] + (b.1[k] - a.1[k]) * u - pts[i].1[k]).abs()).fold(0.0f32, f32::max);
|
||||
if off > 1.5 / 255.0 {
|
||||
keep.push(i);
|
||||
/// Mesh shadings filling `region` (document space): gradient meshes, clipped to the region
|
||||
/// when they reach past it.
|
||||
fn draw_meshes(&mut self, meshes: Vec<vectorcraft_doc::live::GradientMesh>, region: &BezPath, opacity: f32) {
|
||||
let nodes: Vec<Arc<Node>> = meshes.into_iter().map(|m| Arc::new(Node::new(self.id(), NodeKind::Mesh(m)))).collect();
|
||||
let Some(art) = bounds(&nodes) else { return };
|
||||
let mut content = match <[Arc<Node>; 1]>::try_from(nodes) {
|
||||
Ok([one]) => Arc::unwrap_or_clone(one),
|
||||
Err(all) => Node::group(self.id(), all),
|
||||
};
|
||||
content.opacity *= opacity;
|
||||
if !contains(region.bounding_box().inflate(0.5, 0.5), art) {
|
||||
let clip = self.clip_node(region, FillRule::NonZero);
|
||||
content = clipped(clip, content, self.id());
|
||||
}
|
||||
self.push_node(content);
|
||||
}
|
||||
keep.push(N);
|
||||
let mut stop_at = |t: f32, offset: f32| -> GradientStop {
|
||||
let (rgb, _) = sample(t).unwrap_or((Color::BLACK, 1.0));
|
||||
match at(t).and_then(|v| colors.native(space, &v)) {
|
||||
Some(Native { color, link: Some((name, tint)) }) => GradientStop { swatch: Some(name), tint, ..GradientStop::new(offset, color) },
|
||||
Some(Native { color, link: None }) => GradientStop::new(offset, color),
|
||||
None => GradientStop::new(offset, rgb),
|
||||
|
||||
/// The family and style of font `key` (drawing glyph `o`).
|
||||
fn font_name(&mut self, key: u128, o: &hayro_interpret::font::OutlineGlyph) -> (String, String) {
|
||||
if let Some(n) = self.font_names.get(&key) {
|
||||
return n.clone();
|
||||
}
|
||||
};
|
||||
let c = |x: f32, y: f32| m * Point::new(x as f64, y as f64);
|
||||
let (kind, geom, stops) = if *axial {
|
||||
let geom = GradientGeom { start: c(coords[0], coords[1]), end: c(coords[2], coords[3]), aspect: 1.0, focal: None };
|
||||
(GradientKind::Linear, geom, keep.iter().map(|&i| stop_at(pts[i].0, pts[i].0)).collect::<Vec<_>>())
|
||||
} else {
|
||||
let (r0, r1) = (coords[2].max(0.0), coords[5].max(1e-6));
|
||||
// The end circle, mapped (as an ellipse) into the page; a start circle off its centre
|
||||
// gives the focal point.
|
||||
let p = |x: f32, y: f32| Point::new(x as f64, y as f64);
|
||||
let centre = p(coords[3], coords[4]);
|
||||
let mut geom = GradientGeom { start: centre, end: centre + Vec2::new(r1 as f64, 0.0), aspect: 1.0, focal: None };
|
||||
geom.set_focal(Some(p(coords[0], coords[1])));
|
||||
geom.transform(m, GradientKind::Radial);
|
||||
// Offsets are relative to the outer radius; an inner radius shifts them outwards.
|
||||
let stops = keep.iter().map(|&i| stop_at(pts[i].0, (r0 + (r1 - r0) * pts[i].0) / r1)).collect();
|
||||
(GradientKind::Radial, geom, stops)
|
||||
};
|
||||
let mut g = GradientPaint::new(Gradient { kind, stops });
|
||||
g.geom = Some(geom);
|
||||
Some(g)
|
||||
let data = o.font_data();
|
||||
let name = self.fonts.get(&key).cloned().or_else(|| data.as_ref().and_then(|d| d.postscript_name.clone()));
|
||||
let (weight, italic) = data.as_ref().map_or((None, false), |d| (d.weight, d.is_italic));
|
||||
let families = self.families.get_or_insert_with(Families::available);
|
||||
let n = match name {
|
||||
Some(name) => {
|
||||
let (family, style, found) = families.resolve(&name, weight, italic);
|
||||
if !found && !family.is_empty() && !self.missing_fonts.contains(&family) {
|
||||
self.missing_fonts.push(family.clone());
|
||||
}
|
||||
(family, style)
|
||||
}
|
||||
None => {
|
||||
let d = vectorcraft_doc::CharStyle::default();
|
||||
(d.font_family, d.font_style)
|
||||
}
|
||||
};
|
||||
self.font_names.insert(key, n.clone());
|
||||
n
|
||||
}
|
||||
|
||||
/// Glyph `o` as type: added to the line being gathered (or starting one). `false`: it keeps
|
||||
/// its outline (no Unicode text, mirrored, or not type mode).
|
||||
fn type_glyph(
|
||||
&mut self,
|
||||
o: &hayro_interpret::font::OutlineGlyph,
|
||||
m: Affine,
|
||||
scale: f64,
|
||||
paint: &hayro_interpret::Paint<'_>,
|
||||
stroke: Option<&StrokeProps>,
|
||||
) -> bool {
|
||||
if self.text_as != TextAs::Text || self.nested > 0 {
|
||||
return false;
|
||||
}
|
||||
let text = match o.as_unicode() {
|
||||
Some(BfString::Char(c)) => c.to_string(),
|
||||
Some(BfString::String(s)) => s,
|
||||
None => return false,
|
||||
};
|
||||
let Some(at) = Placement::of(m) else { return false };
|
||||
if text.is_empty() || text.chars().any(|c| c.is_control()) {
|
||||
return false;
|
||||
}
|
||||
self.flush_glyphs();
|
||||
let font = o.font_cache_key();
|
||||
let (paint, opacity) = self.paint(paint, stroke.is_some());
|
||||
let stroke = stroke.map(|p| (paint.clone(), p.line_width as f64 * scale));
|
||||
// A stroke over the glyph just filled (fill and stroke rendering).
|
||||
if let (Some(s), Some(line)) = (&stroke, &mut self.text)
|
||||
&& line.stroke_last(font, at, s.clone())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let (family, style) = self.font_name(font, o);
|
||||
let look = Look { font, family, style, size: at.size, h_scale: at.h_scale, fill: stroke.is_none().then_some(paint), stroke };
|
||||
let advance = o.advance_width().filter(|w| w.is_finite()).map_or(at.size * 0.5, |w| w as f64 / 1000.0 * at.size * at.h_scale / 100.0);
|
||||
if !self.text.as_mut().is_some_and(|l| l.push(&look, at, opacity, advance, &text)) {
|
||||
self.flush_text();
|
||||
let mut line = TextLine::new(at, opacity);
|
||||
line.push(&look, at, opacity, advance, &text);
|
||||
self.text = Some(line);
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn rgba_png(rgba: Vec<u8>, w: u32, h: u32) -> Option<Vec<u8>> {
|
||||
|
|
@ -491,24 +823,67 @@ fn resize_alpha(a: &LumaData, w: u32, h: u32) -> Vec<u8> {
|
|||
|
||||
impl<'a> Device<'a> for Builder<'_> {
|
||||
fn set_soft_mask(&mut self, mask: Option<SoftMask<'a>>) {
|
||||
if mask.is_some() {
|
||||
self.warn("soft masks are not imported (content drawn unmasked)");
|
||||
let spec = mask.map(|m| self.soft_mask(&m));
|
||||
let same = match (&spec, &self.mask) {
|
||||
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
if !same {
|
||||
self.flush();
|
||||
self.mask = spec;
|
||||
}
|
||||
}
|
||||
|
||||
fn set_blend_mode(&mut self, blend_mode: hayro_interpret::BlendMode) {
|
||||
self.blend = blend(blend_mode);
|
||||
let b = blend(blend_mode);
|
||||
if b != self.blend {
|
||||
self.flush_glyphs();
|
||||
self.flush_text();
|
||||
self.blend = b;
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_path(&mut self, path: &BezPath, transform: Affine, paint: &hayro_interpret::Paint<'a>, draw_mode: &PathDrawMode) {
|
||||
self.flush_glyphs();
|
||||
self.flush_text();
|
||||
self.pending = false;
|
||||
let mut bp = path.clone();
|
||||
bp.apply_affine(transform);
|
||||
if bp.elements().is_empty() {
|
||||
return;
|
||||
}
|
||||
let shading = match paint {
|
||||
hayro_interpret::Paint::Pattern(p) => match p.as_ref() {
|
||||
Pattern::Shading(sp) => Some(sp),
|
||||
Pattern::Tiling(_) => None,
|
||||
},
|
||||
hayro_interpret::Paint::Color(_) => None,
|
||||
};
|
||||
// Patch and triangle meshes are gradient meshes.
|
||||
if let (PathDrawMode::Fill(_), Some(sp)) = (draw_mode, shading)
|
||||
&& let Some(meshes) = mesh_shading(sp, &mut self.colors)
|
||||
{
|
||||
self.draw_meshes(meshes, &bp, sp.opacity);
|
||||
return;
|
||||
}
|
||||
let pd = PathData::from_bezpath(&bp);
|
||||
let (paint, opacity) = self.paint(paint);
|
||||
let stroke = matches!(draw_mode, PathDrawMode::Stroke(_));
|
||||
let (paint, opacity) = self.paint(paint, stroke);
|
||||
// A gradient that doesn't extend past its ends paints only between them.
|
||||
let width = match draw_mode {
|
||||
PathDrawMode::Stroke(p) => p.line_width as f64 * mean_scale(transform),
|
||||
PathDrawMode::Fill(_) => 0.0,
|
||||
};
|
||||
let band = shading.and_then(|sp| extend_clip(sp, bp.bounding_box().inflate(width, width)));
|
||||
let wrap = |b: &mut Self, n: Node| match &band {
|
||||
Some(band) => {
|
||||
let clip = b.clip_node(band, FillRule::EvenOdd);
|
||||
let id = b.id();
|
||||
clipped(clip, n, id)
|
||||
}
|
||||
None => n,
|
||||
};
|
||||
match draw_mode {
|
||||
PathDrawMode::Fill(rule) => {
|
||||
let mut f = FillLayer::new(paint);
|
||||
|
|
@ -517,14 +892,19 @@ impl<'a> Device<'a> for Builder<'_> {
|
|||
if let NodeKind::Path { rule: r, .. } = &mut n.kind {
|
||||
*r = fill_rule(*rule);
|
||||
}
|
||||
let n = wrap(self, n);
|
||||
self.push_node(n);
|
||||
}
|
||||
PathDrawMode::Stroke(props) => {
|
||||
let st = stroke_layer(paint, opacity, props, mean_scale(transform));
|
||||
// Fill-then-stroke of the same path (the `B` operator) becomes one object.
|
||||
let blend = self.blend;
|
||||
if let Some(f) = self.stack.last_mut()
|
||||
&& let Some(last) = f.children.last_mut()
|
||||
let last = match self.mask {
|
||||
Some(_) => self.masked.last_mut(),
|
||||
None => self.stack.last_mut().and_then(|f| f.children.last_mut()),
|
||||
};
|
||||
if band.is_none()
|
||||
&& let Some(last) = last
|
||||
&& last.blend == blend
|
||||
&& last.path_data() == Some(&pd)
|
||||
&& last.appearance.stroke().is_none()
|
||||
|
|
@ -533,13 +913,25 @@ impl<'a> Device<'a> for Builder<'_> {
|
|||
return;
|
||||
}
|
||||
let n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() });
|
||||
let n = wrap(self, n);
|
||||
self.push_node(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn push_clip_path(&mut self, clip_path: &ClipPath) {
|
||||
self.flush_glyphs();
|
||||
self.flush();
|
||||
// A form's box right after its transparency group: the group's isolate and knockout
|
||||
// flags are the next ones read from the file.
|
||||
if std::mem::take(&mut self.pending) {
|
||||
let flags = self.scan.groups.get(self.group_at).copied();
|
||||
self.group_at += 1;
|
||||
if let Some((i, k)) = flags
|
||||
&& let Some(Frame { kind: FrameKind::Group { isolate, knockout, .. }, .. }) = self.stack.last_mut()
|
||||
{
|
||||
(*isolate, *knockout) = (i, k);
|
||||
}
|
||||
}
|
||||
let bb = clip_path.path.bounding_box();
|
||||
// Clips that contain the whole page do nothing visible; don't create groups for them.
|
||||
let redundant = clip_path.path.elements().len() <= 6
|
||||
|
|
@ -548,32 +940,15 @@ impl<'a> Device<'a> for Builder<'_> {
|
|||
&& bb.x1 >= self.page.x1 - 0.01
|
||||
&& bb.y1 >= self.page.y1 - 0.01
|
||||
&& self.page.area() > 0.0;
|
||||
let kind = if redundant {
|
||||
FrameKind::Skip
|
||||
} else {
|
||||
let mut clip = Node::new(
|
||||
self.id(),
|
||||
NodeKind::Path {
|
||||
path: PathData::from_bezpath(&clip_path.path),
|
||||
rule: fill_rule(clip_path.fill),
|
||||
live: None,
|
||||
clipping: true,
|
||||
guide: false,
|
||||
},
|
||||
);
|
||||
clip.name = Some("<Clipping Path>".into());
|
||||
FrameKind::Clip(Box::new(clip))
|
||||
};
|
||||
self.stack.push(Frame { kind, children: vec![] });
|
||||
let kind = if redundant { FrameKind::Skip } else { FrameKind::Clip(Box::new(self.clip_node(&clip_path.path, fill_rule(clip_path.fill)))) };
|
||||
self.stack.push(Frame::new(kind));
|
||||
}
|
||||
|
||||
fn push_transparency_group(&mut self, opacity: f32, mask: Option<SoftMask<'a>>, blend_mode: hayro_interpret::BlendMode) {
|
||||
self.flush_glyphs();
|
||||
let masked = mask.is_some();
|
||||
if masked {
|
||||
self.warn("soft masks are not imported (content drawn unmasked)");
|
||||
}
|
||||
self.stack.push(Frame { kind: FrameKind::Group { opacity, blend: blend(blend_mode), masked }, children: vec![] });
|
||||
self.flush();
|
||||
let mask = mask.map(|m| self.soft_mask(&m));
|
||||
self.stack.push(Frame::new(FrameKind::Group { opacity, blend: blend(blend_mode), mask, isolate: false, knockout: false }));
|
||||
self.pending = self.nested == 0;
|
||||
// Blend mode applies to the group as a whole, not to its children.
|
||||
self.blend = BlendMode::Normal;
|
||||
}
|
||||
|
|
@ -586,6 +961,7 @@ impl<'a> Device<'a> for Builder<'_> {
|
|||
paint: &hayro_interpret::Paint<'a>,
|
||||
draw_mode: &GlyphDrawMode,
|
||||
) {
|
||||
self.pending = false;
|
||||
let stroke = match draw_mode {
|
||||
GlyphDrawMode::Invisible => return,
|
||||
GlyphDrawMode::Fill => None,
|
||||
|
|
@ -593,10 +969,14 @@ impl<'a> Device<'a> for Builder<'_> {
|
|||
};
|
||||
match glyph {
|
||||
Glyph::Outline(o) => {
|
||||
let scale = mean_scale(transform);
|
||||
if self.type_glyph(o, transform * glyph_transform, scale, paint, stroke.as_ref()) {
|
||||
return;
|
||||
}
|
||||
self.flush_text();
|
||||
let mut bp = o.outline();
|
||||
bp.apply_affine(transform * glyph_transform);
|
||||
let (paint, opacity) = self.paint(paint);
|
||||
let scale = mean_scale(transform);
|
||||
let (paint, opacity) = self.paint(paint, stroke.is_some());
|
||||
let same = self.glyphs.as_ref().is_some_and(|r| {
|
||||
r.paint == paint
|
||||
&& r.opacity == opacity
|
||||
|
|
@ -613,14 +993,21 @@ impl<'a> Device<'a> for Builder<'_> {
|
|||
self.warn("text was converted to outlines");
|
||||
}
|
||||
Glyph::Type3(t3) => {
|
||||
self.flush_glyphs();
|
||||
if self.nested >= MAX_NESTED {
|
||||
return;
|
||||
}
|
||||
self.flush();
|
||||
self.nested += 1;
|
||||
t3.interpret(self, transform, glyph_transform, paint);
|
||||
self.nested -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) {
|
||||
self.flush_glyphs();
|
||||
self.flush_text();
|
||||
self.pending = false;
|
||||
match image {
|
||||
Image::Raster(r) => {
|
||||
let key = hayro_interpret::CacheKey::cache_key(&r);
|
||||
|
|
@ -683,12 +1070,14 @@ impl<'a> Device<'a> for Builder<'_> {
|
|||
}
|
||||
|
||||
fn pop_clip_path(&mut self) {
|
||||
self.flush_glyphs();
|
||||
self.flush();
|
||||
self.pending = false;
|
||||
self.pop_frame();
|
||||
}
|
||||
|
||||
fn pop_transparency_group(&mut self) {
|
||||
self.flush_glyphs();
|
||||
self.flush();
|
||||
self.pending = false;
|
||||
self.pop_frame();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,128 @@
|
|||
//! Soft masks → opacity masks. A luminosity mask's art becomes the mask art; an alpha mask's art
|
||||
//! is turned white (its opacity kept), so its luminance over the black backdrop is its alpha.
|
||||
//! The backdrop colour gives Clip (black: outside the art is hidden), an inverting transfer
|
||||
//! function gives Invert, and the rectangles the PDF export draws for those (a backdrop cover, a
|
||||
//! white Difference cover) read back as the options rather than as art. An alpha mask that is one
|
||||
//! rectangle of constant opacity over everything is plain opacity.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::{Rect, Shape};
|
||||
use vectorcraft_color::blend::MASK_LUM;
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::{AppearanceItem, Node, NodeKind, OpacityMask};
|
||||
|
||||
/// What a soft mask does to what it masks.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum MaskSpec {
|
||||
/// A constant opacity (a group drawn through it is not isolated).
|
||||
Opacity(f32),
|
||||
Mask(OpacityMask),
|
||||
}
|
||||
|
||||
/// The luminance of `c` (0 black … 1 white), as opacity masks weigh it.
|
||||
pub(crate) fn luminance(c: &Color) -> f32 {
|
||||
let rgb = c.to_rgb();
|
||||
rgb.iter().zip(MASK_LUM).map(|(v, w)| v * w).sum()
|
||||
}
|
||||
|
||||
/// A rectangle (at most 6 path elements, its area that of its bounds).
|
||||
pub(crate) fn is_rectangle(p: &kurbo::BezPath) -> bool {
|
||||
let b = p.bounding_box();
|
||||
p.elements().len() <= 6 && b.area() > 0.0 && (p.area().abs() - b.area()).abs() <= b.area() * 1e-3
|
||||
}
|
||||
|
||||
/// A path covering `page` filled with one solid colour and no stroke → its colour and opacity.
|
||||
fn cover(n: &Node, page: Rect) -> Option<(Color, f32)> {
|
||||
let NodeKind::Path { path, .. } = &n.kind else { return None };
|
||||
let [AppearanceItem::Fill(f)] = n.appearance.items.as_slice() else { return None };
|
||||
let Paint::Solid { color, .. } = &f.paint else { return None };
|
||||
let b = path.to_bezpath();
|
||||
let covers = page.area() > 1.0 && contains(b.bounding_box(), page.inset(-0.5)) && is_rectangle(&b);
|
||||
(covers && n.mask.is_none()).then_some((*color, f.opacity * n.opacity))
|
||||
}
|
||||
|
||||
/// Does `outer` contain `inner`?
|
||||
pub(crate) fn contains(outer: Rect, inner: Rect) -> bool {
|
||||
outer.x0 <= inner.x0 && outer.y0 <= inner.y0 && outer.x1 >= inner.x1 && outer.y1 >= inner.y1
|
||||
}
|
||||
|
||||
/// Every paint of `n` (and its children) white, opacities kept: an alpha mask's art as a
|
||||
/// luminosity mask's.
|
||||
fn whiten(n: &mut Node) {
|
||||
let white = |p: &mut Paint| match p {
|
||||
Paint::Solid { color, swatch, tint } => {
|
||||
(*color, *swatch, *tint) = (Color::WHITE, None, 1.0);
|
||||
}
|
||||
Paint::Gradient(g) => {
|
||||
for s in &mut g.gradient.stops {
|
||||
(s.color, s.swatch, s.tint) = (Color::WHITE, None, 1.0);
|
||||
}
|
||||
}
|
||||
Paint::None | Paint::Pattern { .. } => {}
|
||||
};
|
||||
for item in &mut n.appearance.items {
|
||||
white(item.paint_mut());
|
||||
}
|
||||
match &mut n.kind {
|
||||
NodeKind::Mesh(m) => m.points.iter_mut().for_each(|p| p.color = Color::WHITE),
|
||||
NodeKind::Text(t) => t.runs.iter_mut().for_each(|r| {
|
||||
white(&mut r.style.fill);
|
||||
white(&mut r.style.stroke);
|
||||
}),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(ch) = n.children_mut() {
|
||||
for c in ch {
|
||||
whiten(Arc::make_mut(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a soft mask drawing `art` acts: `alpha` for an alpha mask, `backdrop` the luminance outside
|
||||
/// the art, `inverted` when its transfer function inverts it. `page`: the area masks cover.
|
||||
pub(crate) fn mask_spec(
|
||||
mut art: Vec<Arc<Node>>,
|
||||
alpha: bool,
|
||||
backdrop: f32,
|
||||
inverted: bool,
|
||||
page: Rect,
|
||||
group: impl FnOnce(Vec<Arc<Node>>) -> Node,
|
||||
) -> MaskSpec {
|
||||
if alpha {
|
||||
if let [only] = art.as_slice()
|
||||
&& let Some((_, a)) = cover(only, page)
|
||||
{
|
||||
return MaskSpec::Opacity(a);
|
||||
}
|
||||
art.iter_mut().for_each(|n| whiten(Arc::make_mut(n)));
|
||||
}
|
||||
let mut clip = alpha || backdrop < 0.5;
|
||||
let mut invert = inverted;
|
||||
// The PDF export's covers: a white Difference rectangle on top inverts, an opaque one at the
|
||||
// bottom is the backdrop (white: no clip).
|
||||
if !alpha {
|
||||
if art.len() > 1
|
||||
&& let Some(last) = art.last()
|
||||
&& last.blend == BlendMode::Difference
|
||||
&& cover(last, page).is_some_and(|(c, a)| a >= 0.999 && luminance(&c) > 0.999)
|
||||
{
|
||||
art.pop();
|
||||
invert = !invert;
|
||||
}
|
||||
if art.len() > 1
|
||||
&& let Some(first) = art.first()
|
||||
&& first.blend == BlendMode::Normal
|
||||
&& let Some((c, a)) = cover(first, page)
|
||||
&& a >= 0.999
|
||||
{
|
||||
clip = luminance(&c) < 0.5;
|
||||
art.remove(0);
|
||||
}
|
||||
}
|
||||
let art = match <[Arc<Node>; 1]>::try_from(art) {
|
||||
Ok([one]) => Arc::unwrap_or_clone(one),
|
||||
Err(all) => group(all),
|
||||
};
|
||||
MaskSpec::Mask(OpacityMask { invert, ..OpacityMask::new(art, clip) })
|
||||
}
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
//! What the interpreter doesn't tell the import, read from the file itself: the isolate and
|
||||
//! knockout flags of transparency groups, and the names of the fonts.
|
||||
//!
|
||||
//! hayro-interpret reports a transparency group without its flags. So each page's content is
|
||||
//! walked here in the interpreter's order (page content, then form XObjects as they are drawn,
|
||||
//! skipping what it skips, such as optional content that is off): the k-th form transparency
|
||||
//! group it reports is the k-th group found here.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use hayro_interpret::CacheKey;
|
||||
use hayro_syntax::Pdf;
|
||||
use hayro_syntax::content::TypedIter;
|
||||
use hayro_syntax::content::ops::TypedInstruction;
|
||||
use hayro_syntax::object::{Array, Dict, Name, Object, ObjectIdentifier, dict_or_stream};
|
||||
use hayro_syntax::page::{Page, Resources};
|
||||
|
||||
/// The interpreter's limit on nested form XObjects.
|
||||
const MAX_DEPTH: u32 = 50;
|
||||
/// The most content operators walked per page: past it the rest of the page isn't told apart.
|
||||
const MAX_OPS: usize = 2_000_000;
|
||||
/// The most groups read.
|
||||
const MAX_GROUPS: usize = 10_000;
|
||||
|
||||
/// The file's optional content groups.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Ocgs {
|
||||
/// The groups that are off (as the interpreter decides it).
|
||||
off: HashSet<ObjectIdentifier>,
|
||||
}
|
||||
|
||||
impl Ocgs {
|
||||
/// The groups listed in the catalog's `/OCProperties`, with their default states.
|
||||
pub fn read(pdf: &Pdf) -> Self {
|
||||
let mut o = Self::default();
|
||||
let xref = pdf.xref();
|
||||
let Some(props) = xref.get::<Dict<'_>>(xref.root_id()).and_then(|c| c.get::<Dict<'_>>(b"OCProperties")) else {
|
||||
return o;
|
||||
};
|
||||
let config = props.get::<Dict<'_>>(b"D").unwrap_or_default();
|
||||
let refs = |d: &Dict<'_>, key: &[u8]| -> Vec<ObjectIdentifier> {
|
||||
d.get::<Array<'_>>(key)
|
||||
.map(|a| a.raw_iter().filter_map(|i| i.as_obj_ref()).map(ObjectIdentifier::from).take(MAX_GROUPS).collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
if config.get::<Name<'_>>(b"BaseState").is_some_and(|n| n.as_ref() == b"OFF") {
|
||||
o.off.extend(refs(&props, b"OCGs"));
|
||||
}
|
||||
for id in refs(&config, b"ON") {
|
||||
o.off.remove(&id);
|
||||
}
|
||||
o.off.extend(refs(&config, b"OFF"));
|
||||
o
|
||||
}
|
||||
}
|
||||
|
||||
/// One page as the interpreter walks it.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Scan {
|
||||
/// Each form transparency group: isolated, knockout.
|
||||
pub groups: Vec<(bool, bool)>,
|
||||
}
|
||||
|
||||
struct Walker<'w> {
|
||||
/// The groups that are off for the interpreter.
|
||||
off: &'w HashSet<ObjectIdentifier>,
|
||||
fonts: &'w mut HashMap<u128, String>,
|
||||
visible: Vec<bool>,
|
||||
ops: usize,
|
||||
out: Scan,
|
||||
}
|
||||
|
||||
impl Walker<'_> {
|
||||
fn is_visible(&self) -> bool {
|
||||
self.visible.last().copied().unwrap_or(true)
|
||||
}
|
||||
|
||||
fn begin(&mut self, on: bool) {
|
||||
let v = self.is_visible() && on;
|
||||
self.visible.push(v);
|
||||
}
|
||||
|
||||
/// Enter optional content `d` (object `id`): a group, or a membership dictionary whose policy
|
||||
/// decides from its groups.
|
||||
fn begin_oc(&mut self, d: &Dict<'_>, id: Option<ObjectIdentifier>) {
|
||||
let membership = id.is_none() || d.get::<Name<'_>>(b"Type").is_some_and(|t| t.as_ref() == b"OCMD");
|
||||
let on = match id {
|
||||
Some(id) if !membership => !self.off.contains(&id),
|
||||
_ => {
|
||||
let ids: Vec<ObjectIdentifier> = match d.get::<Array<'_>>(b"OCGs") {
|
||||
Some(a) => a.raw_iter().filter_map(|i| i.as_obj_ref()).map(ObjectIdentifier::from).collect(),
|
||||
None => d.get_ref(b"OCGs").map(ObjectIdentifier::from).into_iter().collect(),
|
||||
};
|
||||
let on = |i: &ObjectIdentifier| !self.off.contains(i);
|
||||
ids.is_empty()
|
||||
|| match d.get::<Name<'_>>(b"P").as_deref() {
|
||||
Some(b"AllOn") => ids.iter().all(on),
|
||||
Some(b"AnyOff") => !ids.iter().all(on),
|
||||
Some(b"AllOff") => !ids.iter().any(on),
|
||||
_ => ids.iter().any(on),
|
||||
}
|
||||
}
|
||||
};
|
||||
self.begin(on);
|
||||
}
|
||||
|
||||
fn note_fonts(&mut self, res: &Resources<'_>) {
|
||||
for (key, _) in res.fonts.entries() {
|
||||
if let Some(f) = res.fonts.get::<Dict<'_>>(key.as_ref())
|
||||
&& let Some(name) = f.get::<Name<'_>>(b"BaseFont")
|
||||
{
|
||||
self.fonts.entry(f.cache_key()).or_insert_with(|| String::from_utf8_lossy(name.as_ref()).into_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn walk(&mut self, mut iter: TypedIter<'_>, res: &Resources<'_>, depth: u32) {
|
||||
self.note_fonts(res);
|
||||
while let Some(op) = iter.next() {
|
||||
self.ops += 1;
|
||||
if self.ops > MAX_OPS {
|
||||
return;
|
||||
}
|
||||
match op {
|
||||
TypedInstruction::BeginMarkedContentWithProperties(bdc) => {
|
||||
let oc = match bdc.1 {
|
||||
Object::Name(n) => {
|
||||
res.properties.get_ref(n.as_ref()).map(|r| (res.properties.get::<Dict<'_>>(n.as_ref()).unwrap_or_default(), r))
|
||||
}
|
||||
o => dict_or_stream(o)
|
||||
.and_then(|(props, _)| props.get_ref(b"OC").map(|r| (props.get::<Dict<'_>>(b"OC").unwrap_or_default(), r))),
|
||||
};
|
||||
match oc {
|
||||
Some((d, r)) => self.begin_oc(&d, Some(ObjectIdentifier::from(r))),
|
||||
None => self.begin(true),
|
||||
}
|
||||
}
|
||||
TypedInstruction::BeginMarkedContent(_) => self.begin(true),
|
||||
TypedInstruction::EndMarkedContent(_) => {
|
||||
self.visible.pop();
|
||||
}
|
||||
TypedInstruction::XObject(x) => {
|
||||
if !self.is_visible() || depth >= MAX_DEPTH {
|
||||
continue;
|
||||
}
|
||||
let Some(s) = res.get_x_object(x.0) else { continue };
|
||||
let d = s.dict();
|
||||
if d.get::<Name<'_>>(b"Subtype").as_deref() != Some(b"Form") || d.get::<[f32; 4]>(b"BBox").is_none() {
|
||||
continue;
|
||||
}
|
||||
let Ok(data) = s.decoded() else { continue };
|
||||
let oc = d.get::<Dict<'_>>(b"OC");
|
||||
if let Some(oc) = &oc {
|
||||
self.begin_oc(oc, d.get_ref(b"OC").map(ObjectIdentifier::from));
|
||||
}
|
||||
if self.is_visible() {
|
||||
if let Some(g) = d.get::<Dict<'_>>(b"Group")
|
||||
&& self.out.groups.len() < MAX_GROUPS
|
||||
{
|
||||
self.out.groups.push((g.get::<bool>(b"I").unwrap_or(false), g.get::<bool>(b"K").unwrap_or(false)));
|
||||
}
|
||||
let inner = Resources::from_parent(d.get::<Dict<'_>>(b"Resources").unwrap_or_default(), res.clone());
|
||||
self.walk(TypedIter::new(data.as_ref()), &inner, depth + 1);
|
||||
}
|
||||
if oc.is_some() {
|
||||
self.visible.pop();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk `page` as the interpreter will. Fonts found are noted in `fonts` (their cache key → base
|
||||
/// font name).
|
||||
pub(crate) fn scan_page(page: &Page<'_>, ocgs: &Ocgs, fonts: &mut HashMap<u128, String>) -> Scan {
|
||||
let mut w = Walker { off: &ocgs.off, fonts, visible: vec![], ops: 0, out: Scan::default() };
|
||||
w.walk(page.typed_operations(), page.resources(), 0);
|
||||
w.out
|
||||
}
|
||||
|
|
@ -0,0 +1,311 @@
|
|||
//! Shadings: axial and radial ones become gradients (their stops sampled from the shading
|
||||
//! function in its own colour model; an end that doesn't extend clips the painted area), patch
|
||||
//! and triangle meshes become gradient meshes, and a gradient drawn through a luminosity mask that
|
||||
//! is the same gradient in grey (how stop opacity is written) gets its opacity stops back.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use hayro_interpret::pattern::ShadingPattern;
|
||||
use hayro_interpret::shading::{ShadingFunction, ShadingType};
|
||||
use kurbo::{BezPath, Circle, Point, Rect, Shape, Vec2};
|
||||
use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_doc::live::{GradientMesh, H_DOWN, H_LEFT, H_RIGHT, H_UP, MeshPoint, lerp_color};
|
||||
use vectorcraft_doc::{Node, NodeKind, OpacityMask};
|
||||
|
||||
use crate::import::round3;
|
||||
use crate::import_color::{Colors, Native};
|
||||
use crate::import_mask::luminance;
|
||||
|
||||
/// The most mesh patches (or triangles) imported per shading: past it, the shading is a flat
|
||||
/// colour.
|
||||
const MAX_PATCHES: usize = 4096;
|
||||
|
||||
/// An axial or radial shading as a gradient paint, with whether it extends past its start and
|
||||
/// its end.
|
||||
pub(crate) fn shading_gradient(sp: &ShadingPattern, colors: &mut Colors<'_>) -> Option<(GradientPaint, [bool; 2])> {
|
||||
let ShadingType::RadialAxial { coords, domain, function, axial, extend } = sp.shading.shading_type.as_ref() else {
|
||||
return None;
|
||||
};
|
||||
let m = sp.matrix;
|
||||
let cs = &sp.shading.color_space;
|
||||
let at = |t: f32| function.eval(&smallvec::smallvec![domain[0] + (domain[1] - domain[0]) * t]);
|
||||
let space = colors.shading_space(cs, at(0.0)?.len());
|
||||
// RGB to decide which samples are stops; the stops keep the shading's model.
|
||||
let sample = |t: f32| -> Option<Color> {
|
||||
let [r, g, b, _] = cs.to_rgba(&at(t)?, 1.0, false).components();
|
||||
Some(Color::rgb(round3(r), round3(g), round3(b)))
|
||||
};
|
||||
// Sample densely, then drop samples that linear interpolation reproduces.
|
||||
const N: usize = 64;
|
||||
let mut pts: Vec<(f32, [f32; 3])> = Vec::with_capacity(N + 1);
|
||||
for i in 0..=N {
|
||||
let t = i as f32 / N as f32;
|
||||
pts.push((t, sample(t)?.to_rgb()));
|
||||
}
|
||||
let mut keep = vec![0usize];
|
||||
for w in 1..N {
|
||||
let (Some(a), Some(p), Some(b)) = (keep.last().and_then(|&k| pts.get(k)), pts.get(w), pts.get(w + 1)) else { continue };
|
||||
let u = (p.0 - a.0) / (b.0 - a.0).max(1e-6);
|
||||
let off = (0..3).map(|k| (a.1[k] + (b.1[k] - a.1[k]) * u - p.1[k]).abs()).fold(0.0f32, f32::max);
|
||||
if off > 1.5 / 255.0 {
|
||||
keep.push(w);
|
||||
}
|
||||
}
|
||||
keep.push(N);
|
||||
let ts: Vec<f32> = keep.iter().filter_map(|&k| pts.get(k)).map(|p| p.0).collect();
|
||||
let mut stop_at = |t: f32, offset: f32| -> GradientStop {
|
||||
let rgb = sample(t).unwrap_or(Color::BLACK);
|
||||
match at(t).and_then(|v| colors.native(space, &v)) {
|
||||
Some(Native { color, link: Some((name, tint)) }) => GradientStop { swatch: Some(name), tint, ..GradientStop::new(offset, color) },
|
||||
Some(Native { color, link: None }) => GradientStop::new(offset, color),
|
||||
None => GradientStop::new(offset, rgb),
|
||||
}
|
||||
};
|
||||
let c = |x: f32, y: f32| m * Point::new(x as f64, y as f64);
|
||||
let (kind, geom, stops) = if *axial {
|
||||
let geom = GradientGeom { start: c(coords[0], coords[1]), end: c(coords[2], coords[3]), aspect: 1.0, focal: None };
|
||||
(GradientKind::Linear, geom, ts.iter().map(|&t| stop_at(t, t)).collect::<Vec<_>>())
|
||||
} else {
|
||||
let (r0, r1) = (coords[2].max(0.0), coords[5].max(1e-6));
|
||||
// The end circle, mapped (as an ellipse) into the page; a start circle off its centre
|
||||
// gives the focal point.
|
||||
let p = |x: f32, y: f32| Point::new(x as f64, y as f64);
|
||||
let centre = p(coords[3], coords[4]);
|
||||
let mut geom = GradientGeom { start: centre, end: centre + Vec2::new(r1 as f64, 0.0), aspect: 1.0, focal: None };
|
||||
geom.set_focal(Some(p(coords[0], coords[1])));
|
||||
geom.transform(m, GradientKind::Radial);
|
||||
// Offsets are relative to the outer radius; an inner radius shifts them outwards.
|
||||
let stops = ts.iter().map(|&t| stop_at(t, (r0 + (r1 - r0) * t) / r1)).collect();
|
||||
(GradientKind::Radial, geom, stops)
|
||||
};
|
||||
let mut g = GradientPaint::new(Gradient { kind, stops });
|
||||
g.geom = Some(geom);
|
||||
Some((g, *extend))
|
||||
}
|
||||
|
||||
/// Where an axial or radial shading that doesn't extend at both ends paints, as a clip path in
|
||||
/// document space (even-odd) for art filling `bounds`; `None` when it extends at both.
|
||||
pub(crate) fn extend_clip(sp: &ShadingPattern, bounds: Rect) -> Option<BezPath> {
|
||||
let ShadingType::RadialAxial { coords, axial, extend, .. } = sp.shading.shading_type.as_ref() else {
|
||||
return None;
|
||||
};
|
||||
if extend[0] && extend[1] {
|
||||
return None;
|
||||
}
|
||||
let m = sp.matrix;
|
||||
let inv = m.inverse();
|
||||
let local = inv.transform_rect_bbox(bounds);
|
||||
if !local.is_finite() || m.determinant().abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let f = |i: usize| coords.get(i).copied().unwrap_or(0.0) as f64;
|
||||
let mut out = BezPath::new();
|
||||
if *axial {
|
||||
let (p0, p1) = (Point::new(f(0), f(1)), Point::new(f(2), f(3)));
|
||||
let len = (p1 - p0).hypot();
|
||||
if len < 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let u = (p1 - p0) / len;
|
||||
let n = Vec2::new(-u.y, u.x);
|
||||
let corners = [local.origin(), Point::new(local.x1, local.y0), Point::new(local.x1, local.y1), Point::new(local.x0, local.y1)];
|
||||
let along = corners.map(|c| (c - p0).dot(u));
|
||||
let across = corners.map(|c| (c - p0).dot(n));
|
||||
let min = |v: [f64; 4]| v.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let max = |v: [f64; 4]| v.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||||
let lo = if extend[0] { min(along) } else { min(along).max(0.0) };
|
||||
let hi = if extend[1] { max(along) } else { max(along).min(len) };
|
||||
let (b0, b1) = (min(across), max(across));
|
||||
if hi > lo {
|
||||
let q = |a: f64, b: f64| m * (p0 + u * a + n * b);
|
||||
out.move_to(q(lo, b0));
|
||||
out.line_to(q(hi, b0));
|
||||
out.line_to(q(hi, b1));
|
||||
out.line_to(q(lo, b1));
|
||||
out.close_path();
|
||||
}
|
||||
return Some(out);
|
||||
}
|
||||
let start = Circle::new((f(0), f(1)), f(2).max(0.0));
|
||||
let end = Circle::new((f(3), f(4)), f(5).max(0.0));
|
||||
// Past the end: the end circle bounds the paint; before the start: the start circle is a hole.
|
||||
let outer = if extend[1] { local.inflate(1.0, 1.0).to_path(0.1) } else { end.to_path(0.1) };
|
||||
out.extend(m * outer);
|
||||
if !extend[0] && start.radius > 1e-9 {
|
||||
out.extend(m * start.to_path(0.1));
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The colour of shading components `v` (the input of the shading's function, when it has one).
|
||||
fn mesh_color(v: &[f32], function: Option<&ShadingFunction>, sp: &ShadingPattern, colors: &mut Colors<'_>) -> Color {
|
||||
let comps = match function {
|
||||
Some(f) => f.eval(&v.iter().copied().collect()).map(|c| c.to_vec()).unwrap_or_default(),
|
||||
None => v.to_vec(),
|
||||
};
|
||||
let space = colors.shading_space(&sp.shading.color_space, comps.len());
|
||||
colors.native(space, &comps).map(|n| n.color).unwrap_or_else(|| {
|
||||
let [r, g, b, _] = sp.shading.color_space.to_rgba(&comps, 1.0, false).components();
|
||||
Color::rgb(round3(r), round3(g), round3(b))
|
||||
})
|
||||
}
|
||||
|
||||
/// A 1×1 gradient mesh from a patch's four corners (bottom-left, top-left, top-right,
|
||||
/// bottom-right in the patch's u/v order), each with its handles towards its neighbours.
|
||||
fn patch_mesh(corners: [(Point, [Vec2; 4], Color); 4]) -> GradientMesh {
|
||||
let point = |(p, handles, color): (Point, [Vec2; 4], Color)| MeshPoint { p, color, opacity: 1.0, handles };
|
||||
let [a, b, c, d] = corners;
|
||||
// Row 0 is v = 0 (corners a, d), row 1 is v = 1 (b, c); columns follow u.
|
||||
GradientMesh { rows: 1, cols: 1, points: vec![point(a), point(d), point(b), point(c)] }
|
||||
}
|
||||
|
||||
/// Handles of a mesh corner: right, left, down, up (unused ones stay at the point).
|
||||
fn handles(right: Vec2, left: Vec2, down: Vec2, up: Vec2) -> [Vec2; 4] {
|
||||
let mut h = [Vec2::ZERO; 4];
|
||||
(h[H_RIGHT], h[H_LEFT], h[H_DOWN], h[H_UP]) = (right, left, down, up);
|
||||
h
|
||||
}
|
||||
|
||||
/// A 1×1 mesh from a patch's twelve boundary points (in the PDF's order: u runs cp0 → cp9 along
|
||||
/// cp11 and cp10, v runs cp0 → cp3 along cp1 and cp2) and its corner colours (at cp0, cp3, cp6,
|
||||
/// cp9); `None` if a point isn't finite.
|
||||
fn boundary_mesh(cp: &[Point], colors: [Color; 4]) -> Option<GradientMesh> {
|
||||
let cp: &[Point; 12] = cp.get(..12)?.try_into().ok()?;
|
||||
if !cp.iter().all(|p| p.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let h = |a: usize, b: usize| cp[b] - cp[a];
|
||||
let [c0, c1, c2, c3] = colors;
|
||||
Some(patch_mesh([
|
||||
(cp[0], handles(h(0, 11), Vec2::ZERO, h(0, 1), Vec2::ZERO), c0),
|
||||
(cp[3], handles(h(3, 4), Vec2::ZERO, Vec2::ZERO, h(3, 2)), c1),
|
||||
(cp[6], handles(Vec2::ZERO, h(6, 5), Vec2::ZERO, h(6, 7)), c2),
|
||||
(cp[9], handles(Vec2::ZERO, h(9, 10), h(9, 8), Vec2::ZERO), c3),
|
||||
]))
|
||||
}
|
||||
|
||||
/// A patch mesh or triangle mesh shading as gradient meshes in document space (one per patch;
|
||||
/// a triangle is a patch with two corners together). `None`: not a mesh, or too big.
|
||||
pub(crate) fn mesh_shading(sp: &ShadingPattern, colors: &mut Colors<'_>) -> Option<Vec<GradientMesh>> {
|
||||
let m = sp.matrix;
|
||||
let mut out = vec![];
|
||||
match sp.shading.shading_type.as_ref() {
|
||||
ShadingType::CoonsPatchMesh { patches, function } => {
|
||||
if patches.len() > MAX_PATCHES {
|
||||
return None;
|
||||
}
|
||||
for p in patches {
|
||||
let colors = [0, 1, 2, 3].map(|i| mesh_color(p.colors.get(i).map_or(&[][..], |c| c.as_slice()), function.as_ref(), sp, colors));
|
||||
out.extend(boundary_mesh(&p.control_points.map(|q| m * q), colors));
|
||||
}
|
||||
}
|
||||
// A tensor patch's boundary is a Coons patch's (its four inner points are dropped).
|
||||
ShadingType::TensorProductPatchMesh { patches, function } => {
|
||||
if patches.len() > MAX_PATCHES {
|
||||
return None;
|
||||
}
|
||||
for p in patches {
|
||||
let colors = [0, 1, 2, 3].map(|i| mesh_color(p.colors.get(i).map_or(&[][..], |c| c.as_slice()), function.as_ref(), sp, colors));
|
||||
out.extend(boundary_mesh(&p.control_points.map(|q| m * q), colors));
|
||||
}
|
||||
}
|
||||
ShadingType::TriangleMesh { triangles, function } => {
|
||||
if triangles.len() > MAX_PATCHES {
|
||||
return None;
|
||||
}
|
||||
for t in triangles {
|
||||
let v = [&t.p0, &t.p1, &t.p2].map(|v| (m * v.point, mesh_color(v.colors.as_slice(), function.as_ref(), sp, colors)));
|
||||
if !v.iter().all(|(p, _)| p.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
let [a, b, c] = v;
|
||||
out.push(patch_mesh([
|
||||
(a.0, [Vec2::ZERO; 4], a.1),
|
||||
(b.0, [Vec2::ZERO; 4], b.1),
|
||||
(c.0, [Vec2::ZERO; 4], c.1),
|
||||
(c.0, [Vec2::ZERO; 4], c.1),
|
||||
]));
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The only painted leaf of mask art (through groups of one object and clip groups of one).
|
||||
fn only_leaf(n: &Node) -> Option<&Node> {
|
||||
match &n.kind {
|
||||
NodeKind::Group { children, clip } => {
|
||||
let rest = if *clip { children.get(1..)? } else { children.as_slice() };
|
||||
match rest {
|
||||
[one] if n.opacity >= 0.999 && n.mask.is_none() => only_leaf(one),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
NodeKind::Path { .. } => Some(n),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The gradient a path paints with its only fill or stroke.
|
||||
fn only_gradient(n: &Node) -> Option<&GradientPaint> {
|
||||
match n.appearance.items.as_slice() {
|
||||
[item] => match item.paint() {
|
||||
Paint::Gradient(g) => Some(g),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn same_geom(a: &GradientGeom, b: &GradientGeom) -> bool {
|
||||
let near = |p: Point, q: Point| p.distance(q) < 0.5;
|
||||
near(a.start, b.start) && near(a.end, b.end) && near(a.focal_point(), b.focal_point()) && (a.aspect - b.aspect).abs() < 0.01
|
||||
}
|
||||
|
||||
/// `content` (a path painting one gradient) drawn through `mask` (clipped, not inverted, the same
|
||||
/// gradient in shades of grey: how gradient stop opacity is written) as one gradient with the
|
||||
/// mask's luminance as stop opacity.
|
||||
pub(crate) fn fold_stop_opacity(content: &Node, mask: &OpacityMask) -> Option<Node> {
|
||||
if !mask.clip || mask.invert || mask.disabled || content.is_container() {
|
||||
return None;
|
||||
}
|
||||
let g = only_gradient(content)?;
|
||||
let leaf = only_leaf(&mask.art)?;
|
||||
let mg = only_gradient(leaf)?;
|
||||
let opaque = |n: &Node| n.opacity >= 0.999 && n.appearance.items.iter().all(|i| i.opacity() >= 0.999);
|
||||
if mg.gradient.kind != g.gradient.kind || !opaque(leaf) || !same_geom(g.geom.as_ref()?, mg.geom.as_ref()?) {
|
||||
return None;
|
||||
}
|
||||
let mut offsets: Vec<f32> = g.gradient.stops.iter().chain(&mg.gradient.stops).map(|s| s.offset).collect();
|
||||
offsets.sort_by(f32::total_cmp);
|
||||
offsets.dedup_by(|a, b| (*a - *b).abs() < 1e-4);
|
||||
let stops = offsets
|
||||
.iter()
|
||||
.map(|&t| {
|
||||
let (mc, mo) = mg.gradient.sample(t);
|
||||
let alpha = round3(luminance(&mc) * mo);
|
||||
let mut s = match g.gradient.stops.iter().find(|s| (s.offset - t).abs() < 1e-4) {
|
||||
Some(s) => s.clone(),
|
||||
None => {
|
||||
let (c, o) = g.gradient.sample_with(t, lerp_color);
|
||||
GradientStop { opacity: o, ..GradientStop::new(t, c) }
|
||||
}
|
||||
};
|
||||
s.opacity = round3(s.opacity * alpha);
|
||||
s
|
||||
})
|
||||
.collect();
|
||||
let mut out = content.clone();
|
||||
let mut paint = (*g).clone();
|
||||
paint.gradient.stops = stops;
|
||||
if let Some(item) = out.appearance.items.first_mut() {
|
||||
*item.paint_mut() = Paint::Gradient(Box::new(paint));
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `node` (filling `bounds`) clipped to `clip`: a clip group.
|
||||
pub(crate) fn clipped(clip: Node, node: Node, id: vectorcraft_doc::NodeId) -> Node {
|
||||
Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(node)], clip: true })
|
||||
}
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
//! Text as editable point type: glyphs drawn one after another on the same baseline make one
|
||||
//! point text object of their Unicode text, a run per font, size and paint (a gap wider than a
|
||||
//! fifth of the size reads as a space). Fonts are named from the file's base font name (its
|
||||
//! subset prefix dropped, the family matched against the fonts available); a font that isn't
|
||||
//! available keeps its name, and the text shows in the fallback font until it is.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use kurbo::{Affine, Point, Vec2};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{CharStyle, TextObject, TextRun};
|
||||
|
||||
/// One glyph's placement: its baseline origin, advance direction, size and horizontal scale.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Placement {
|
||||
pub origin: Point,
|
||||
/// Unit vector along the baseline.
|
||||
pub dir: Vec2,
|
||||
/// Font size in points (the em's height).
|
||||
pub size: f64,
|
||||
/// Width of the em ÷ its height, in percent.
|
||||
pub h_scale: f64,
|
||||
}
|
||||
|
||||
impl Placement {
|
||||
/// Where a glyph drawn with `m` (glyph space, 1000 units per em, y up → document) sits;
|
||||
/// `None` when it is mirrored, degenerate or not finite (those keep their outlines).
|
||||
pub fn of(m: Affine) -> Option<Self> {
|
||||
let origin = m * Point::ORIGIN;
|
||||
let ex = m * Point::new(1000.0, 0.0) - origin;
|
||||
let ey = m * Point::new(0.0, 1000.0) - origin;
|
||||
let (w, size) = (ex.hypot(), ey.hypot());
|
||||
// Upright in y-down document space: x to the right of up.
|
||||
let upright = ex.cross(ey) < 0.0;
|
||||
(origin.is_finite() && w.is_finite() && size.is_finite() && w > 0.01 && size > 0.01 && size < 1e5 && upright).then(|| Self {
|
||||
origin,
|
||||
dir: ex / w,
|
||||
size,
|
||||
h_scale: w / size * 100.0,
|
||||
})
|
||||
}
|
||||
|
||||
fn up(&self) -> Vec2 {
|
||||
Vec2::new(self.dir.y, -self.dir.x)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a run of type is drawn with: its font (cache key, family and style), size, horizontal
|
||||
/// scale, fill and stroke (paint and width).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Look {
|
||||
pub font: u128,
|
||||
pub family: String,
|
||||
pub style: String,
|
||||
pub size: f64,
|
||||
pub h_scale: f64,
|
||||
pub fill: Option<Paint>,
|
||||
pub stroke: Option<(Paint, f64)>,
|
||||
}
|
||||
|
||||
impl Look {
|
||||
/// Can a glyph drawn with `other` join a run drawn with this? A fill joins a run whose
|
||||
/// glyphs were also stroked over (fill and stroke rendering draws each glyph twice).
|
||||
fn takes(&self, other: &Look) -> bool {
|
||||
self.font == other.font
|
||||
&& (self.size - other.size).abs() <= self.size * 0.01
|
||||
&& (self.h_scale - other.h_scale).abs() < 0.5
|
||||
&& self.fill == other.fill
|
||||
&& (other.stroke.is_none() || self.stroke == other.stroke)
|
||||
}
|
||||
|
||||
fn style(&self) -> CharStyle {
|
||||
let (stroke, stroke_width) = self.stroke.clone().unwrap_or((Paint::None, 0.0));
|
||||
CharStyle {
|
||||
font_family: self.family.clone(),
|
||||
font_style: self.style.clone(),
|
||||
size: round(self.size),
|
||||
h_scale: round(self.h_scale),
|
||||
fill: self.fill.clone().unwrap_or(Paint::None),
|
||||
stroke,
|
||||
stroke_width,
|
||||
..CharStyle::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A line of type being gathered: runs of glyphs on one baseline.
|
||||
pub(crate) struct TextLine {
|
||||
/// The first glyph's placement.
|
||||
at: Placement,
|
||||
opacity: f32,
|
||||
/// Where the next glyph would start without spacing.
|
||||
next: Point,
|
||||
/// The last glyph's origin.
|
||||
last: Point,
|
||||
runs: Vec<(Look, String)>,
|
||||
}
|
||||
|
||||
impl TextLine {
|
||||
pub fn new(at: Placement, opacity: f32) -> Self {
|
||||
Self { at, opacity, next: at.origin, last: at.origin, runs: vec![] }
|
||||
}
|
||||
|
||||
/// Add glyph `text` drawn with `look` at `at` (advancing `advance` points) at `opacity` if
|
||||
/// it continues this line; `false`: it starts another.
|
||||
pub fn push(&mut self, look: &Look, at: Placement, opacity: f32, advance: f64, text: &str) -> bool {
|
||||
if let Some((last, run)) = self.runs.last_mut() {
|
||||
let size = self.at.size.max(at.size);
|
||||
let gap = (at.origin - self.next).dot(self.at.dir);
|
||||
let on_line = opacity == self.opacity
|
||||
&& at.dir.dot(self.at.dir) > 0.9995
|
||||
&& (at.origin - self.at.origin).dot(self.at.up()).abs() < size * 0.15
|
||||
&& gap > -size * 0.3
|
||||
&& gap < size * 3.0;
|
||||
if !on_line {
|
||||
return false;
|
||||
}
|
||||
// A gap wider than a fifth of an em reads as a space.
|
||||
if gap > size * 0.2 && !run.ends_with(' ') && !text.starts_with(' ') {
|
||||
run.push(' ');
|
||||
}
|
||||
if last.takes(look) {
|
||||
run.push_str(text);
|
||||
} else {
|
||||
self.runs.push((look.clone(), text.to_string()));
|
||||
}
|
||||
} else {
|
||||
self.runs.push((look.clone(), text.to_string()));
|
||||
}
|
||||
self.next = at.origin + self.at.dir * advance;
|
||||
self.last = at.origin;
|
||||
true
|
||||
}
|
||||
|
||||
/// A stroke (`stroke`: paint and width) over the glyph just drawn at `at` in `font` (fill
|
||||
/// and stroke rendering): the run is stroked. `false`: it isn't that glyph.
|
||||
pub fn stroke_last(&mut self, font: u128, at: Placement, stroke: (Paint, f64)) -> bool {
|
||||
let Some((look, _)) = self.runs.last_mut().filter(|(l, _)| l.font == font && (at.origin - self.last).hypot() < self.at.size * 1e-3) else {
|
||||
return false;
|
||||
};
|
||||
look.stroke = Some(stroke);
|
||||
true
|
||||
}
|
||||
|
||||
/// The point type object and its opacity.
|
||||
pub fn finish(mut self) -> Option<(TextObject, f32)> {
|
||||
if let Some((_, last)) = self.runs.last_mut() {
|
||||
last.truncate(last.trim_end().len());
|
||||
}
|
||||
self.runs.retain(|(_, t)| !t.is_empty());
|
||||
if self.runs.iter().all(|(_, t)| t.trim().is_empty()) {
|
||||
return None;
|
||||
}
|
||||
let mut runs = self.runs.into_iter().map(|(look, text)| TextRun { text, style: look.style() });
|
||||
let first = runs.next()?;
|
||||
let mut t = TextObject::point(Point::ORIGIN, &first.text, first.style);
|
||||
t.runs.extend(runs);
|
||||
t.xf = Affine::translate(self.at.origin.to_vec2()) * Affine::rotate(self.at.dir.atan2());
|
||||
Some((t, self.opacity))
|
||||
}
|
||||
}
|
||||
|
||||
fn round(v: f64) -> f64 {
|
||||
(v * 1000.0).round() / 1000.0
|
||||
}
|
||||
|
||||
/// Lower-case letters and digits only (`"Source Sans 3"` → `"sourcesans3"`).
|
||||
fn norm(s: &str) -> String {
|
||||
s.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()).collect()
|
||||
}
|
||||
|
||||
/// `"TimesNewRoman"` → `"Times New Roman"`, `"SourceSans3"` → `"Source Sans 3"`.
|
||||
fn spaced(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 4);
|
||||
let mut prev: Option<char> = None;
|
||||
for c in s.chars() {
|
||||
if let Some(p) = prev
|
||||
&& ((p.is_ascii_lowercase() && c.is_ascii_uppercase()) || (p.is_ascii_alphabetic() && c.is_ascii_digit()))
|
||||
{
|
||||
out.push(' ');
|
||||
}
|
||||
out.push(c);
|
||||
prev = Some(c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `name` without a PostScript name's trailing `PSMT`, `MT` or `PS`.
|
||||
fn strip_ps(name: &str) -> &str {
|
||||
["PSMT", "MT", "PS"].iter().find_map(|s| name.strip_suffix(s).filter(|r| !r.is_empty())).unwrap_or(name)
|
||||
}
|
||||
|
||||
/// The families available, by [`norm`]ed name.
|
||||
pub(crate) struct Families(HashMap<String, String>);
|
||||
|
||||
impl Families {
|
||||
pub fn available() -> Self {
|
||||
Self(vectorcraft_text::FontDb::global().families().into_iter().map(|f| (norm(&f), f)).collect())
|
||||
}
|
||||
|
||||
/// The family and style of a font with base (PostScript) name `name`, or of weight `weight`
|
||||
/// and slant `italic` when the name has no style; whether the family is available.
|
||||
pub fn resolve(&self, name: &str, weight: Option<u32>, italic: bool) -> (String, String, bool) {
|
||||
// A subset's six-letter tag.
|
||||
let name = match name.split_once('+') {
|
||||
Some((tag, rest)) if tag.len() == 6 && tag.chars().all(|c| c.is_ascii_uppercase()) => rest,
|
||||
_ => name,
|
||||
};
|
||||
let (fam, style) = name.split_once(['-', ',']).unwrap_or((name, ""));
|
||||
let found = [fam, strip_ps(fam)].iter().find_map(|f| self.0.get(&norm(f)).cloned());
|
||||
let style = match spaced(strip_ps(style)).as_str() {
|
||||
"" | "Roman" | "Book" | "Normal" | "Plain" => match (weight.is_some_and(|w| w >= 600) || style.contains("Bold"), italic) {
|
||||
(true, true) => "Bold Italic".to_string(),
|
||||
(true, false) => "Bold".to_string(),
|
||||
(false, true) => "Italic".to_string(),
|
||||
(false, false) => "Regular".to_string(),
|
||||
},
|
||||
s => s.to_string(),
|
||||
};
|
||||
match found {
|
||||
Some(f) => (f, style, true),
|
||||
None => (spaced(strip_ps(fam)).trim().to_string(), style, false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,9 +11,11 @@
|
|||
//! Advanced, Security); options the writer doesn't apply yet come back as warnings. With Preserve
|
||||
//! Editing the native document rides along as an embedded file ([`editing()`]).
|
||||
//! - [`import`] reads PDF (and PDF-compatible `.ai`) pages with `hayro-interpret` into a
|
||||
//! [`Document`]: one artboard and one layer per page, paths with fill/stroke, clip groups,
|
||||
//! transparency groups, axial/radial shadings → gradients, images (JPEG passthrough, others
|
||||
//! re-encoded as PNG) and text as glyph outlines. Colours keep their model: CMYK, Gray, and spot
|
||||
//! [`Document`]: one artboard and one layer per page, paths with fill/stroke, clip groups, transparency groups (with isolation and knockout), soft
|
||||
//! masks → opacity masks, axial/radial shadings → gradients (stop opacity and unextended ends
|
||||
//! kept), mesh shadings → gradient meshes, tiling patterns → pattern swatches, images (JPEG
|
||||
//! passthrough, others re-encoded as PNG) and text as point type (or glyph outlines, see
|
||||
//! [`TextAs`]). Colours keep their model: CMYK, Gray, and spot
|
||||
//! inks (Separation, DeviceN) as spot swatches at a tint; a file painted mostly in CMYK opens as a
|
||||
//! CMYK document. [`ImportOptions`] pick the pages, the box each
|
||||
//! artboard gets ([`CropTo`]) and the password; [`info`] lists the pages and their boxes.
|
||||
|
|
@ -26,6 +28,10 @@ mod export;
|
|||
mod images;
|
||||
mod import;
|
||||
mod import_color;
|
||||
mod import_mask;
|
||||
mod import_scan;
|
||||
mod import_shading;
|
||||
mod import_text;
|
||||
mod lab_spot;
|
||||
mod pages;
|
||||
mod presets;
|
||||
|
|
@ -76,14 +82,26 @@ pub struct ImportOptions {
|
|||
pub crop: CropTo,
|
||||
/// The (user) password of an encrypted PDF.
|
||||
pub password: Option<String>,
|
||||
/// What text becomes.
|
||||
pub text_as: TextAs,
|
||||
}
|
||||
|
||||
impl Default for ImportOptions {
|
||||
fn default() -> Self {
|
||||
Self { max_pages: None, artboard_gap: 36.0, pages: None, crop: CropTo::default(), password: None }
|
||||
Self { max_pages: None, artboard_gap: 36.0, pages: None, crop: CropTo::default(), password: None, text_as: TextAs::default() }
|
||||
}
|
||||
}
|
||||
|
||||
settings::choice! {
|
||||
/// What imported text becomes.
|
||||
TextAs {
|
||||
/// Point type, one object per line run, in the file's fonts (by name).
|
||||
Text = "text", "Text";
|
||||
/// Paths of the glyphs' outlines (looks the same without the fonts).
|
||||
Outlines = "outlines", "Outlines";
|
||||
} default Text
|
||||
}
|
||||
|
||||
settings::choice! {
|
||||
/// The page box an imported or placed page is cropped to: its artboard (or placed frame).
|
||||
CropTo {
|
||||
|
|
@ -168,6 +186,8 @@ mod tests_fx;
|
|||
#[cfg(test)]
|
||||
mod tests_import_color;
|
||||
#[cfg(test)]
|
||||
mod tests_import_fidelity;
|
||||
#[cfg(test)]
|
||||
mod tests_import_options;
|
||||
#[cfg(test)]
|
||||
mod tests_presets;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,361 @@
|
|||
//! Import fidelity: soft masks → opacity masks, isolated and knockout groups, tiling patterns →
|
||||
//! pattern swatches, text → point type, mesh shadings → gradient meshes, gradient stop opacity
|
||||
//! and shading Extend flags.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Knockout, Node, NodeId, NodeKind, OpacityMask};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with};
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// A stream object with dictionary entries `dict`.
|
||||
pub(crate) fn stream(dict: &str, data: &str) -> String {
|
||||
format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len())
|
||||
}
|
||||
|
||||
/// A 100 × 100 pt page drawing `content` with `resources`, plus `extra` objects.
|
||||
fn one_page(content: &str, resources: &str, extra: &[&str]) -> Vec<u8> {
|
||||
let page = PdfPage { resources: resources.into(), ..PdfPage::new(100.0, 100.0, content) };
|
||||
pdf_with(&[page], extra, None)
|
||||
}
|
||||
|
||||
fn open(bytes: &[u8]) -> Document {
|
||||
import(bytes).unwrap()
|
||||
}
|
||||
|
||||
fn open_with(bytes: &[u8], f: impl FnOnce(&mut ImportOptions)) -> ImportReport {
|
||||
let mut o = ImportOptions::default();
|
||||
f(&mut o);
|
||||
import_with_report(bytes, &o).unwrap()
|
||||
}
|
||||
|
||||
/// Every node of the document, depth first.
|
||||
fn all(d: &Document) -> Vec<Node> {
|
||||
let mut out = vec![];
|
||||
d.walk(|n| out.push(n.clone()));
|
||||
out
|
||||
}
|
||||
|
||||
fn rect(r: Rect, c: Color) -> Node {
|
||||
Node::path(NodeId(0), shapes::rectangle(r), Appearance::basic(Paint::solid(c), Paint::None, 0.0))
|
||||
}
|
||||
|
||||
fn add(d: &mut Document, mut n: Node) {
|
||||
n.id = d.alloc_id();
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), usize::MAX, n).unwrap();
|
||||
}
|
||||
|
||||
fn roundtrip(d: &Document) -> Document {
|
||||
open(&export(d, &PdfOptions::default()).unwrap())
|
||||
}
|
||||
|
||||
/// The fill colour of a node's first fill.
|
||||
fn fill_color(n: &Node) -> Option<Color> {
|
||||
n.appearance.fill().and_then(|f| f.paint.color())
|
||||
}
|
||||
|
||||
fn near(a: Rect, b: Rect) -> bool {
|
||||
[a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|v| v.abs() < 0.6)
|
||||
}
|
||||
|
||||
// ---------- soft masks ----------
|
||||
|
||||
/// A page filling a red square through soft mask `smask` (its group, object 5, draws `mask`).
|
||||
fn masked(smask: &str, mask: &str) -> Vec<u8> {
|
||||
let g = first_extra(1);
|
||||
let group = stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /CS /DeviceRGB >>", mask);
|
||||
one_page("/GS0 gs 1 0 0 rg 0 0 100 100 re f", &format!("/ExtGState << /GS0 << /SMask << /Type /Mask {smask} /G {g} 0 R >> >> >>"), &[&group])
|
||||
}
|
||||
|
||||
fn the_mask(d: &Document) -> (Node, OpacityMask) {
|
||||
let n = all(d).into_iter().find(|n| n.mask.is_some()).expect("a masked object");
|
||||
let m = n.mask.as_deref().unwrap().clone();
|
||||
(n, m)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_luminosity_soft_mask_becomes_an_opacity_mask() {
|
||||
let d = open(&masked("/S /Luminosity", "1 1 1 rg 0 0 50 100 re f"));
|
||||
let (n, m) = the_mask(&d);
|
||||
assert!(n.path_data().is_some(), "the mask is on the square itself");
|
||||
assert!(m.clip && !m.invert);
|
||||
assert_eq!(fill_color(&m.art).map(|c| c.to_rgb()), Some([1.0, 1.0, 1.0]));
|
||||
assert!(near(m.art.geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 50.0, 100.0)), "{:?}", m.art.geometric_bounds());
|
||||
// A white backdrop: outside the art stays visible (no clip); an inverting transfer function inverts.
|
||||
let (_, m) = the_mask(&open(&masked("/S /Luminosity /BC [1 1 1]", "0 0 0 rg 0 0 50 100 re f")));
|
||||
assert!(!m.clip);
|
||||
let tr = "/TR << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>";
|
||||
let (_, m) = the_mask(&open(&masked(&format!("/S /Luminosity {tr}"), "1 1 1 rg 0 0 50 100 re f")));
|
||||
assert!(m.invert && m.clip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alpha_soft_mask_becomes_an_opacity_mask_of_its_shape() {
|
||||
// The art is painted white with its opacity kept: its luminance is its alpha.
|
||||
let bytes = {
|
||||
let g = first_extra(1);
|
||||
let group = stream(
|
||||
"/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /CS /DeviceRGB >> /Resources << /ExtGState << /GA << /ca 0.5 >> >> >>",
|
||||
"/GA gs 0 0 1 rg 0 0 50 100 re f",
|
||||
);
|
||||
one_page(
|
||||
"/GS0 gs 1 0 0 rg 0 0 100 100 re f",
|
||||
&format!("/ExtGState << /GS0 << /SMask << /Type /Mask /S /Alpha /G {g} 0 R >> >> >>"),
|
||||
&[&group],
|
||||
)
|
||||
};
|
||||
let (_, m) = the_mask(&open(&bytes));
|
||||
assert!(m.clip && !m.invert);
|
||||
let f = m.art.appearance.fill().unwrap();
|
||||
assert_eq!(f.paint.color().map(|c| c.to_rgb()), Some([1.0, 1.0, 1.0]));
|
||||
assert!((f.opacity * m.art.opacity - 0.5).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_masks_round_trip_through_pdf() {
|
||||
for (clip, invert) in [(true, false), (false, false), (true, true), (false, true)] {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let mut n = rect(Rect::new(10.0, 10.0, 90.0, 90.0), Color::rgb(1.0, 0.0, 0.0));
|
||||
let art = rect(Rect::new(10.0, 10.0, 50.0, 90.0), Color::gray(0.25));
|
||||
n.mask = Some(Box::new(OpacityMask { invert, ..OpacityMask::new(art, clip) }));
|
||||
add(&mut d, n);
|
||||
let back = roundtrip(&d);
|
||||
let (n, m) = the_mask(&back);
|
||||
assert!(near(n.geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 90.0, 90.0)));
|
||||
assert_eq!((m.clip, m.invert), (clip, invert), "clip {clip}, invert {invert}");
|
||||
assert!(near(m.art.geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 50.0, 90.0)), "{:?}", m.art.geometric_bounds());
|
||||
let lum = crate::import_mask::luminance(&fill_color(&m.art).unwrap());
|
||||
assert!((lum - 0.75).abs() < 0.02, "{lum}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- isolation and knockout ----------
|
||||
|
||||
/// A 50% group (isolated or not) holding a Multiply square over a backdrop.
|
||||
fn half_group(isolate: bool) -> Document {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
add(&mut d, rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb(0.8, 0.6, 0.2)));
|
||||
let mut m = rect(Rect::new(20.0, 20.0, 80.0, 80.0), Color::rgb(0.5, 0.5, 1.0));
|
||||
(m.id, m.blend) = (d.alloc_id(), BlendMode::Multiply);
|
||||
let mut g = Node::group(NodeId(0), vec![Arc::new(m)]);
|
||||
(g.opacity, g.isolate) = (0.5, isolate);
|
||||
add(&mut d, g);
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolated_and_non_isolated_groups_round_trip() {
|
||||
let back = roundtrip(&half_group(true));
|
||||
let groups: Vec<Node> = all(&back).into_iter().filter(|n| matches!(n.kind, NodeKind::Group { clip: false, .. })).collect();
|
||||
let [g] = groups.as_slice() else { panic!("one group: {groups:?}") };
|
||||
assert!(g.isolate && g.mask.is_none());
|
||||
assert!((g.opacity - 0.5).abs() < 0.01, "{}", g.opacity);
|
||||
assert_eq!(g.children().unwrap()[0].blend, BlendMode::Multiply);
|
||||
// Not isolated: its 50% (a constant alpha mask in the file) is opacity again, here on the
|
||||
// only object inside, which blends with the backdrop as before.
|
||||
let back = roundtrip(&half_group(false));
|
||||
let nodes = all(&back);
|
||||
assert!(nodes.iter().all(|n| !n.isolate && n.mask.is_none()));
|
||||
let m = nodes.iter().find(|n| n.blend == BlendMode::Multiply).unwrap();
|
||||
let opacity: f32 =
|
||||
nodes.iter().filter(|n| n.children().is_some_and(|c| c.iter().any(|c| c.id == m.id))).map(|g| g.opacity).product::<f32>() * m.opacity;
|
||||
assert!((opacity - 0.5).abs() < 0.01, "{opacity}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolate_and_knockout_flags_of_a_group_are_kept() {
|
||||
let x = first_extra(1);
|
||||
let form = stream(
|
||||
"/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /I true /K true >> /Resources << /ExtGState << /M << /BM /Multiply >> >> >>",
|
||||
"/M gs 1 0 0 rg 10 10 50 50 re f 0 0 1 rg 30 30 50 50 re f",
|
||||
);
|
||||
let d = open(&one_page("0 1 0 rg 0 0 100 100 re f /X0 Do", &format!("/XObject << /X0 {x} 0 R >>"), &[&form]));
|
||||
let groups: Vec<Node> = all(&d).into_iter().filter(|n| matches!(n.kind, NodeKind::Group { .. })).collect();
|
||||
let [g] = groups.as_slice() else { panic!("one group: {groups:?}") };
|
||||
assert!(g.isolate);
|
||||
assert_eq!(g.knockout, Knockout::On);
|
||||
assert_eq!(g.children().unwrap().len(), 2);
|
||||
// Without the flags, the form's group is just its art.
|
||||
let plain = stream(
|
||||
"/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency >>",
|
||||
"1 0 0 rg 10 10 50 50 re f 0 0 1 rg 30 30 50 50 re f",
|
||||
);
|
||||
let d = open(&one_page("/X0 Do", &format!("/XObject << /X0 {x} 0 R >>"), &[&plain]));
|
||||
assert!(all(&d).iter().all(|n| !matches!(n.kind, NodeKind::Group { .. })));
|
||||
}
|
||||
|
||||
// ---------- tiling patterns ----------
|
||||
|
||||
#[test]
|
||||
fn tiling_patterns_become_pattern_swatches() {
|
||||
let p = first_extra(1);
|
||||
let colored = stream("/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >>", "1 0 0 rg 0 0 5 5 re f");
|
||||
let d = open(&one_page("/Pattern cs /P0 scn 0 0 100 50 re f 0 50 100 50 re f", &format!("/Pattern << /P0 {p} 0 R >>"), &[&colored]));
|
||||
assert_eq!(d.patterns.len(), 1, "one swatch for both fills");
|
||||
let def = &d.patterns[0];
|
||||
assert!(near(def.tile, Rect::new(0.0, 0.0, 10.0, 10.0)), "{:?}", def.tile);
|
||||
assert_eq!(def.art.len(), 1);
|
||||
assert_eq!(fill_color(&def.art[0]), Some(Color::rgb(1.0, 0.0, 0.0)));
|
||||
assert!(d.swatches.iter().any(|s| matches!(&s.paint, Paint::Pattern { pattern, .. } if *pattern == def.name)), "a pattern swatch");
|
||||
let fills: Vec<Paint> = all(&d).iter().filter_map(|n| n.appearance.fill().map(|f| f.paint.clone())).collect();
|
||||
assert_eq!(fills.len(), 2);
|
||||
assert!(fills.iter().all(|f| matches!(f, Paint::Pattern { pattern, .. } if *pattern == def.name)), "{fills:?}");
|
||||
// An uncoloured pattern draws in the current colour: one swatch per colour.
|
||||
let stencil = stream("/PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >>", "0 0 5 5 re f");
|
||||
let d = open(&one_page(
|
||||
"/CS0 cs 1 0 0 /P0 scn 0 0 100 50 re f 0 0 1 /P0 scn 0 50 100 50 re f",
|
||||
&format!("/Pattern << /P0 {p} 0 R >> /ColorSpace << /CS0 [/Pattern /DeviceRGB] >>"),
|
||||
&[&stencil],
|
||||
));
|
||||
let colors: Vec<Color> = d.patterns.iter().filter_map(|p| fill_color(&p.art[0])).collect();
|
||||
assert_eq!(colors, vec![Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)]);
|
||||
}
|
||||
|
||||
// ---------- text ----------
|
||||
|
||||
const HELVETICA: &str =
|
||||
"/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> /F2 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >> >>";
|
||||
|
||||
fn texts(d: &Document) -> Vec<vectorcraft_doc::TextObject> {
|
||||
all(d).into_iter().filter_map(|n| if let NodeKind::Text(t) = n.kind { Some(*t) } else { None }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_becomes_point_type() {
|
||||
let bytes = one_page(
|
||||
"BT /F1 24 Tf 1 0 0 rg 10 30 Td (Hello) Tj [(Wor) -20 (ld)] TJ ET BT /F1 12 Tf 10 70 Td [(Next) -1000 (line)] TJ /F2 12 Tf 0 0 1 rg ( in bold) Tj ET",
|
||||
HELVETICA,
|
||||
&[],
|
||||
);
|
||||
let r = open_with(&bytes, |_| {});
|
||||
let t = texts(&r.document);
|
||||
assert_eq!(t.len(), 2, "{t:?}");
|
||||
let text = |o: &vectorcraft_doc::TextObject| o.runs.iter().map(|r| r.text.as_str()).collect::<String>();
|
||||
assert_eq!(text(&t[0]), "HelloWorld");
|
||||
let st = &t[0].runs[0].style;
|
||||
assert_eq!((st.font_family.as_str(), st.font_style.as_str(), st.size), ("Helvetica", "Regular", 24.0));
|
||||
assert_eq!(st.fill.color(), Some(Color::rgb(1.0, 0.0, 0.0)));
|
||||
// The baseline origin, in y-down page space.
|
||||
let o = t[0].xf * kurbo::Point::ORIGIN;
|
||||
assert!((o.x - 10.0).abs() < 0.01 && (o.y - 70.0).abs() < 0.01, "{o:?}");
|
||||
// A gap of an em between words reads as a space; another font on the line is another run.
|
||||
assert_eq!(text(&t[1]), "Next line in bold");
|
||||
let runs: Vec<(&str, &str)> = t[1].runs.iter().map(|r| (r.text.as_str(), r.style.font_style.as_str())).collect();
|
||||
assert_eq!(runs, [("Next line", "Regular"), (" in bold", "Bold")]);
|
||||
assert_eq!(t[1].runs[1].style.fill.color(), Some(Color::rgb(0.0, 0.0, 1.0)));
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("outlines")), "{:?}", r.warnings);
|
||||
// As outlines: paths, no type.
|
||||
let r = open_with(&bytes, |o| o.text_as = TextAs::Outlines);
|
||||
assert!(texts(&r.document).is_empty());
|
||||
assert!(all(&r.document).iter().any(|n| n.name.as_deref() == Some("<Text Outlines>")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_names_resolve_to_available_families() {
|
||||
let f = crate::import_text::Families::available();
|
||||
let (family, style, found) = f.resolve("ABCDEF+SourceSans3-Bold", None, false);
|
||||
assert_eq!((family.as_str(), style.as_str(), found), ("Source Sans 3", "Bold", true));
|
||||
let (family, style, found) = f.resolve("TimesNewRomanPS-BoldItalicMT", None, false);
|
||||
assert_eq!((family.as_str(), style.as_str()), ("Times New Roman", "Bold Italic"));
|
||||
let _ = found;
|
||||
let (family, style, found) = f.resolve("MissingSansPro", Some(700), true);
|
||||
assert_eq!((family.as_str(), style.as_str(), found), ("Missing Sans Pro", "Bold Italic", false));
|
||||
}
|
||||
|
||||
// ---------- meshes, stops and extend ----------
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02X}")).collect::<Vec<_>>().join("") + ">"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_coons_patch_mesh_becomes_a_gradient_mesh() {
|
||||
// One patch over (10, 10)–(90, 90): corner colours red (0, 0), green (0, 1), blue (1, 1),
|
||||
// white (1, 0) in u/v.
|
||||
let pts: [(u8, u8); 12] =
|
||||
[(10, 10), (10, 37), (10, 63), (10, 90), (37, 90), (63, 90), (90, 90), (90, 63), (90, 37), (90, 10), (63, 10), (37, 10)];
|
||||
let mut data = vec![0u8];
|
||||
for (x, y) in pts {
|
||||
data.extend([x, y]);
|
||||
}
|
||||
data.extend([255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255]);
|
||||
let s = first_extra(1);
|
||||
let sh = stream(
|
||||
"/ShadingType 6 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 255 0 255 0 1 0 1 0 1] /Filter /ASCIIHexDecode",
|
||||
&hex(&data),
|
||||
);
|
||||
let d = open(&one_page("/Sh0 sh", &format!("/Shading << /Sh0 {s} 0 R >>"), &[&sh]));
|
||||
let meshes: Vec<vectorcraft_doc::live::GradientMesh> =
|
||||
all(&d).into_iter().filter_map(|n| if let NodeKind::Mesh(m) = n.kind { Some(m) } else { None }).collect();
|
||||
let [m] = meshes.as_slice() else { panic!("one mesh: {meshes:?}") };
|
||||
assert_eq!((m.rows, m.cols), (1, 1));
|
||||
let at = |r, c| &m.points[m.idx(r, c)];
|
||||
// Row 0 is v = 0 (PDF y 10 → page y 90), column 1 is u = 1.
|
||||
assert!((at(0, 0).p - kurbo::Point::new(10.0, 90.0)).hypot() < 0.01, "{:?}", at(0, 0).p);
|
||||
assert!((at(1, 1).p - kurbo::Point::new(90.0, 10.0)).hypot() < 0.01, "{:?}", at(1, 1).p);
|
||||
assert_eq!(at(0, 0).color.to_rgb(), [1.0, 0.0, 0.0]);
|
||||
assert_eq!(at(1, 0).color.to_rgb(), [0.0, 1.0, 0.0]);
|
||||
assert_eq!(at(1, 1).color.to_rgb(), [0.0, 0.0, 1.0]);
|
||||
assert_eq!(at(0, 1).color.to_rgb(), [1.0, 1.0, 1.0]);
|
||||
assert!(!all(&d).iter().any(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })), "the mesh lies inside the page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_stop_opacity_round_trips() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let mut stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
stops[1].opacity = 0.2;
|
||||
let g = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
|
||||
let n = Node::path(
|
||||
NodeId(0),
|
||||
shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 90.0)),
|
||||
Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 0.0),
|
||||
);
|
||||
add(&mut d, n);
|
||||
let back = roundtrip(&d);
|
||||
let nodes = all(&back);
|
||||
assert!(nodes.iter().all(|n| n.mask.is_none()), "the opacity is in the stops, not a mask");
|
||||
let g = nodes.iter().find_map(|n| match n.appearance.fill().map(|f| &f.paint) {
|
||||
Some(Paint::Gradient(g)) => Some(g.gradient.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let g = g.expect("a gradient fill");
|
||||
let first = g.stops.first().unwrap();
|
||||
let last = g.stops.last().unwrap();
|
||||
assert!((first.opacity - 1.0).abs() < 0.02 && (last.opacity - 0.2).abs() < 0.02, "{:?}", g.stops);
|
||||
assert_eq!(last.color.to_rgb(), [0.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shading_that_does_not_extend_paints_only_between_its_ends() {
|
||||
let shading = |extend: &str| {
|
||||
format!(
|
||||
"/Shading << /Sh0 << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [20 0 80 0] /Extend [{extend}] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> >> >>"
|
||||
)
|
||||
};
|
||||
let d = open(&one_page("/Sh0 sh", &shading("false false"), &[]));
|
||||
let clips: Vec<Node> = all(&d).into_iter().filter(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })).collect();
|
||||
let [c] = clips.as_slice() else { panic!("one clip group: {clips:?}") };
|
||||
let b = c.geometric_bounds().unwrap();
|
||||
assert!((b.x0 - 20.0).abs() < 0.01 && (b.x1 - 80.0).abs() < 0.01 && b.y0 <= 0.0 && b.y1 >= 100.0, "{b:?}");
|
||||
let d = open(&one_page("/Sh0 sh", &shading("true true"), &[]));
|
||||
assert!(!all(&d).iter().any(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })));
|
||||
// Extended at the start only: from the left edge to the end.
|
||||
let d = open(&one_page("/Sh0 sh", &shading("true false"), &[]));
|
||||
let b = all(&d).into_iter().find(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })).unwrap().geometric_bounds().unwrap();
|
||||
assert!(b.x0 <= 0.0 && (b.x1 - 80.0).abs() < 0.01, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appearance_items_are_untouched_for_plain_art() {
|
||||
// Plain art keeps importing as before: no masks, groups or clips appear.
|
||||
let d = open(&one_page("q 1 0 0 rg 10 10 30 30 re f Q 0 0 1 RG 2 w 50 50 30 30 re S", "", &[]));
|
||||
let nodes = all(&d);
|
||||
assert_eq!(nodes.iter().filter(|n| n.path_data().is_some()).count(), 2);
|
||||
assert!(nodes.iter().all(|n| n.mask.is_none() && !matches!(n.kind, NodeKind::Group { .. })));
|
||||
assert!(matches!(nodes.iter().find(|n| n.appearance.stroke().is_some()).unwrap().appearance.items[0], AppearanceItem::Stroke(_)));
|
||||
}
|
||||
11
docs/mcp.md
11
docs/mcp.md
|
|
@ -235,6 +235,17 @@ and Separation and DeviceN inks become spot swatches the art links to at its tin
|
|||
PostScript files (`.eps`, and `.ai` files saved in older formats or without PDF compatibility) can't be opened yet and
|
||||
say so, as does an `.ai` whose PDF part is only a placeholder page.
|
||||
|
||||
What a PDF holds comes in as editable art: soft masks become opacity masks (an alpha mask as a white copy of its art;
|
||||
the backdrop colour gives Clip, an inverting transfer function Invert), transparency groups keep isolation and knockout,
|
||||
tiling patterns become pattern swatches, patch and triangle mesh shadings become gradient meshes, and gradients keep
|
||||
their stop opacity and stop where the shading doesn't extend. Text becomes point type, one object per run of a line in
|
||||
the file's font (by name; fonts that aren't available are listed in `warnings` and show in the fallback font) —
|
||||
`textAs: "outlines"` keeps glyph outlines instead:
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"document.open","params":{"path":"/tmp/map.pdf","textAs":"outlines"}}}
|
||||
```
|
||||
|
||||
Preserve Editing (`preserveEditing`, on in the `VectorCraft Default` preset) embeds the native document in the PDF as
|
||||
an embedded file (`vectorcraft-editing.vectorcraft`; files named `drawcraft-editing.drawcraft` are read too) with a
|
||||
hash of the pages it was written with. `open_file` / `document.open` of such a PDF, `.ai` or `.ait` (no `pages` picked) restores the
|
||||
|
|
|
|||
Loading…
Reference in New Issue