Merge pull request #98 from storytold/pr/m4-pdf-import-fidelity
M4.61–M4.62: PDF import fidelity and layers
This commit is contained in:
commit
522f05c540
|
|
@ -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 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), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), 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,14 +4,14 @@
|
|||
|
||||
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.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
/// The `document.open` options besides the file. The PDF ones (pages, box, password, text,
|
||||
/// layers) are ignored by other formats.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct LoadOptions {
|
||||
/// 1-based pages to import, such as `"2-3, 5"`; `None` = every page.
|
||||
pub pages: Option<String>,
|
||||
|
|
@ -22,11 +22,23 @@ 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,
|
||||
/// A PDF's optional content groups become layers (else one layer per page, without the art
|
||||
/// that is off).
|
||||
pub layers: bool,
|
||||
}
|
||||
|
||||
impl Default for LoadOptions {
|
||||
fn default() -> Self {
|
||||
Self { pages: None, crop: CropTo::default(), password: None, color_mode: None, text_as: TextAs::default(), layers: true }
|
||||
}
|
||||
}
|
||||
|
||||
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`),
|
||||
/// `textAs` (`text` | `outlines`) and `layers` (true | false).
|
||||
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 +47,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,13 +55,19 @@ 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"))?;
|
||||
let layers = match p.get("layers").filter(|v| !v.is_null()) {
|
||||
None => true,
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "layers must be true or false"))?,
|
||||
};
|
||||
Ok(Self { pages, crop, password, color_mode, text_as, layers })
|
||||
}
|
||||
|
||||
/// Does the document read only part of the file, or read it differently (a page range, another
|
||||
/// box, a password)? Writing it back would lose the rest, so Save doesn't (it asks for a name).
|
||||
/// box, a password, without its hidden layers)? Writing it back would lose the rest, so Save
|
||||
/// doesn't (it asks for a name).
|
||||
pub fn is_partial(&self) -> bool {
|
||||
self.pages.is_some() || self.crop != CropTo::default() || self.password.is_some()
|
||||
self.pages.is_some() || self.crop != CropTo::default() || self.password.is_some() || !self.layers
|
||||
}
|
||||
|
||||
/// The PDF import options: `pages` resolved against the file's page count.
|
||||
|
|
@ -69,20 +79,45 @@ 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,
|
||||
layers: self.layers,
|
||||
..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) and one
|
||||
/// layer of what it shows (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,
|
||||
layers: false,
|
||||
..Default::default()
|
||||
};
|
||||
vectorcraft_pdf::import_with_report(bytes, &opts).map(|r| (r.document, r.warnings)).map_err(err)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
//! `document.open` PDF options `textAs` and `layers`.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_pdf::TextAs;
|
||||
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf, pdf_with_catalog};
|
||||
|
||||
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 layers_option_keeps_or_flattens_optional_content() {
|
||||
let (a, b) = (first_extra(1), first_extra(1) + 1);
|
||||
let catalog = format!("/OCProperties << /OCGs [{a} 0 R {b} 0 R] /D << /OFF [{b} 0 R] >> >> ");
|
||||
let page = PdfPage {
|
||||
resources: format!("/Properties << /A {a} 0 R /B {b} 0 R >>"),
|
||||
..PdfPage::new(100.0, 100.0, "/OC /A BDC 0 g 0 0 10 10 re f EMC /OC /B BDC 0 g 20 20 10 10 re f EMC")
|
||||
};
|
||||
let bytes = pdf_with_catalog(&[page], &["<< /Type /OCG /Name (Ink) >>", "<< /Type /OCG /Name (Notes) >>"], &catalog, None);
|
||||
let mut s = Session::new();
|
||||
open(&mut s, &bytes, json!({})).unwrap();
|
||||
let layers: Vec<(String, bool)> = s.active().unwrap().doc.layers.iter().map(|l| (l.name.clone().unwrap_or_default(), l.visible)).collect();
|
||||
assert_eq!(layers, [("Ink".to_string(), true), ("Notes".to_string(), false)]);
|
||||
open(&mut s, &bytes, json!({"layers": false})).unwrap();
|
||||
let doc = &s.active().unwrap().doc;
|
||||
assert_eq!(doc.layers.len(), 1);
|
||||
assert_eq!(doc.layers[0].children().unwrap().len(), 1, "the hidden art is left out");
|
||||
assert!(matches!(doc.layers[0].kind, NodeKind::Layer { .. }));
|
||||
assert!(open(&mut s, &bytes, json!({"layers": "no"})).unwrap_err().to_string().contains("layers must be true or false"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_options_read_text_and_layers() {
|
||||
let o = LoadOptions::from_params("x", &json!({"textAs": "Outlines", "layers": false})).unwrap();
|
||||
assert_eq!((o.text_as, o.layers), (TextAs::Outlines, false));
|
||||
assert!(o.is_partial(), "without its hidden layers, Save doesn't write the file back");
|
||||
let d = LoadOptions::default();
|
||||
assert_eq!((d.text_as, d.layers, d.is_partial()), (TextAs::Text, true, false));
|
||||
}
|
||||
|
|
@ -435,6 +435,87 @@ proptest! {
|
|||
}
|
||||
}
|
||||
|
||||
/// Hostile content over layers (optional content, one group off), soft masks, tiling patterns,
|
||||
/// a patch mesh, an unextended shading and fonts: the import's own readings of the file.
|
||||
fn rich_resources_pdf(content: &str, config: &str) -> Vec<u8> {
|
||||
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with_catalog};
|
||||
let x = first_extra(1);
|
||||
let stream = |dict: &str, data: &str| format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len());
|
||||
let extra = [
|
||||
"<< /Type /OCG /Name (A) >>".to_string(),
|
||||
"<< /Type /OCG /Name <FEFF> /Usage << /Print << /PrintState /OFF >> >> >>".to_string(),
|
||||
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 /OC /MC0 BDC 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>",
|
||||
),
|
||||
stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /OC 6 0 R", "/X0 Do"),
|
||||
];
|
||||
let (a, b, form, pat, mesh, hidden) = (x, x + 1, x + 2, x + 3, x + 4, x + 5);
|
||||
let resources = format!(
|
||||
"/Properties << /MC0 {a} 0 R /MC1 {b} 0 R >> /XObject << /X0 {form} 0 R /X1 {hidden} 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 catalog = format!("/OCProperties << /OCGs [{a} 0 R {b} 0 R 99 0 R] /D << {} >> >> ", config.replace("{B}", &format!("{b} 0 R")));
|
||||
let extra: Vec<&str> = extra.iter().map(String::as_str).collect();
|
||||
pdf_with_catalog(&[PdfPage { resources, ..PdfPage::new(100.0, 100.0, content) }], &extra, &catalog, None)
|
||||
}
|
||||
|
||||
fn arb_rich_op() -> impl Strategy<Value = String> {
|
||||
prop_oneof![
|
||||
arb_pdf_op(),
|
||||
prop::sample::select(vec![
|
||||
"/OC /MC0 BDC",
|
||||
"/OC /MC1 BDC",
|
||||
"/OC /Nope BDC",
|
||||
"/Span << /MCID 3 >> BDC",
|
||||
"/X BMC",
|
||||
"EMC",
|
||||
"/X0 Do",
|
||||
"/X1 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_layers_masks_patterns_and_text_never_panic(
|
||||
ops in prop::collection::vec(arb_rich_op(), 0..40),
|
||||
config in prop::sample::select(vec!["/OFF [{B}]", "/BaseState /OFF", "/BaseState /Unchanged /ON [{B}] /Locked [{B}]", "", "/OFF {B}"]),
|
||||
layers in any::<bool>(),
|
||||
outlines in any::<bool>(),
|
||||
) {
|
||||
let bytes = rich_resources_pdf(&ops.join("\n"), config);
|
||||
let opts = vectorcraft_pdf::ImportOptions {
|
||||
layers,
|
||||
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.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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,331 @@
|
|||
//! What the interpreter doesn't tell the import, read from the file itself: the optional content
|
||||
//! groups (layers) with their states, which group each marked-content sequence belongs to, the
|
||||
//! isolate and knockout flags of transparency groups, and the names of the fonts.
|
||||
//!
|
||||
//! hayro-interpret reports a marked-content sequence by its tag alone and a transparency group
|
||||
//! without its flags, and skips the content of groups that are off. 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): the k-th `BDC`/`BMC` it reports is the k-th one found here (its tag
|
||||
//! checks that), and the k-th form transparency group is the k-th group found here. To import
|
||||
//! content that is off as hidden layers, [`all_on`] gives the file an update that turns every
|
||||
//! group on.
|
||||
|
||||
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, FromBytes, 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;
|
||||
|
||||
/// An optional content group: a layer.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Ocg {
|
||||
pub name: String,
|
||||
/// On in the file's default configuration (shown by viewers).
|
||||
pub on: bool,
|
||||
/// Printed (its print usage isn't off).
|
||||
pub print: bool,
|
||||
pub locked: bool,
|
||||
}
|
||||
|
||||
/// The file's optional content groups.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Ocgs {
|
||||
pub list: Vec<Ocg>,
|
||||
index: HashMap<ObjectIdentifier, usize>,
|
||||
/// 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()
|
||||
};
|
||||
let all = refs(&props, b"OCGs");
|
||||
if config.get::<Name<'_>>(b"BaseState").is_some_and(|n| n.as_ref() == b"OFF") {
|
||||
o.off.extend(all.iter().copied());
|
||||
}
|
||||
for id in refs(&config, b"ON") {
|
||||
o.off.remove(&id);
|
||||
}
|
||||
o.off.extend(refs(&config, b"OFF"));
|
||||
let locked: HashSet<_> = refs(&config, b"Locked").into_iter().collect();
|
||||
for id in all {
|
||||
if let Some(d) = xref.get::<Dict<'_>>(id) {
|
||||
o.add(id, &d, locked.contains(&id));
|
||||
}
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// Note group `id` (its dictionary `d`) → its index.
|
||||
fn add(&mut self, id: ObjectIdentifier, d: &Dict<'_>, locked: bool) -> usize {
|
||||
if let Some(i) = self.index.get(&id) {
|
||||
return *i;
|
||||
}
|
||||
let name = d.get::<hayro_syntax::object::String<'_>>(b"Name").map(|s| text_string(s.as_bytes())).unwrap_or_default();
|
||||
let print = d
|
||||
.get::<Dict<'_>>(b"Usage")
|
||||
.and_then(|u| u.get::<Dict<'_>>(b"Print"))
|
||||
.and_then(|p| p.get::<Name<'_>>(b"PrintState"))
|
||||
.is_none_or(|s| s.as_ref() != b"OFF");
|
||||
let on = !self.off.contains(&id);
|
||||
self.list.push(Ocg { name: if name.is_empty() { format!("Layer {}", self.list.len() + 1) } else { name }, on, print, locked });
|
||||
self.index.insert(id, self.list.len() - 1);
|
||||
self.list.len() - 1
|
||||
}
|
||||
|
||||
/// Is any group off?
|
||||
pub fn any_off(&self) -> bool {
|
||||
self.list.iter().any(|g| !g.on)
|
||||
}
|
||||
|
||||
/// The group content marked with `/OC` (`d`: the group or membership dictionary, `id` its
|
||||
/// object) belongs to: a membership dictionary's first group.
|
||||
fn group_of(&mut self, d: &Dict<'_>, id: ObjectIdentifier, xref: &hayro_syntax::xref::XRef) -> Option<usize> {
|
||||
if d.get::<Name<'_>>(b"Type").is_some_and(|t| t.as_ref() == b"OCMD") {
|
||||
let first = d.get::<Array<'_>>(b"OCGs").and_then(|a| a.raw_iter().find_map(|i| i.as_obj_ref())).or_else(|| d.get_ref(b"OCGs"))?;
|
||||
let id = ObjectIdentifier::from(first);
|
||||
return self.note(id, &xref.get::<Dict<'_>>(id)?);
|
||||
}
|
||||
self.note(id, d)
|
||||
}
|
||||
|
||||
/// The index of group `id` (dictionary `d`), noted if new (up to [`MAX_GROUPS`]).
|
||||
fn note(&mut self, id: ObjectIdentifier, d: &Dict<'_>) -> Option<usize> {
|
||||
match self.index.get(&id) {
|
||||
Some(i) => Some(*i),
|
||||
None => (self.list.len() < MAX_GROUPS).then(|| self.add(id, d, false)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A PDF text string: UTF-16 (with its byte order mark), UTF-8 (with one) or PDFDocEncoding
|
||||
/// (read as Latin-1, which it matches for letters).
|
||||
pub(crate) fn text_string(b: &[u8]) -> String {
|
||||
if let Some(rest) = b.strip_prefix(&[0xFE, 0xFF]) {
|
||||
let units: Vec<u16> = rest.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect();
|
||||
return String::from_utf16_lossy(&units);
|
||||
}
|
||||
if let Some(rest) = b.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
|
||||
return String::from_utf8_lossy(rest).into_owned();
|
||||
}
|
||||
b.iter().map(|&c| c as char).collect()
|
||||
}
|
||||
|
||||
/// One page as the interpreter walks it.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Scan {
|
||||
/// Each marked-content sequence: its tag and the group (index into [`Ocgs::list`]) it marks.
|
||||
pub tags: Vec<(Vec<u8>, Option<usize>)>,
|
||||
/// Each form transparency group: isolated, knockout.
|
||||
pub groups: Vec<(bool, bool)>,
|
||||
}
|
||||
|
||||
struct Walker<'w, 'p> {
|
||||
ocgs: &'w mut Ocgs,
|
||||
/// The groups that are off for the interpreter (none once [`all_on`] turned them on).
|
||||
off: HashSet<ObjectIdentifier>,
|
||||
xref: &'p hayro_syntax::xref::XRef,
|
||||
fonts: &'w mut HashMap<u128, String>,
|
||||
visible: Vec<bool>,
|
||||
ops: usize,
|
||||
out: Scan,
|
||||
/// A form XObject hidden by its own optional content was met.
|
||||
hidden_form: bool,
|
||||
}
|
||||
|
||||
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))),
|
||||
};
|
||||
let group = match oc {
|
||||
Some((d, r)) => {
|
||||
let id = ObjectIdentifier::from(r);
|
||||
self.begin_oc(&d, Some(id));
|
||||
self.ocgs.group_of(&d, id, self.xref)
|
||||
}
|
||||
None => {
|
||||
self.begin(true);
|
||||
None
|
||||
}
|
||||
};
|
||||
self.out.tags.push((bdc.0.to_vec(), group));
|
||||
}
|
||||
TypedInstruction::BeginMarkedContent(bmc) => {
|
||||
self.begin(true);
|
||||
self.out.tags.push((bmc.0.to_vec(), None));
|
||||
}
|
||||
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));
|
||||
self.hidden_form |= !self.is_visible();
|
||||
}
|
||||
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: `all_on` when [`all_on`] turned every group on. Fonts
|
||||
/// found are noted in `fonts` (their cache key → base font name).
|
||||
pub(crate) fn scan_page(page: &Page<'_>, ocgs: &mut Ocgs, all_on: bool, fonts: &mut HashMap<u128, String>) -> Scan {
|
||||
walk_page(page, ocgs, all_on, fonts).out
|
||||
}
|
||||
|
||||
fn walk_page<'w, 'p>(page: &Page<'p>, ocgs: &'w mut Ocgs, all_on: bool, fonts: &'w mut HashMap<u128, String>) -> Walker<'w, 'p> {
|
||||
let off = if all_on { HashSet::new() } else { ocgs.off.clone() };
|
||||
let mut w = Walker { ocgs, off, xref: page.xref(), fonts, visible: vec![], ops: 0, out: Scan::default(), hidden_form: false };
|
||||
w.walk(page.typed_operations(), page.resources(), 0);
|
||||
w
|
||||
}
|
||||
|
||||
/// Does `page` draw a form XObject that is off by its own optional content (not a marked-content
|
||||
/// sequence)? Its art couldn't be told apart by group once every group is on.
|
||||
pub(crate) fn hides_forms(page: &Page<'_>, ocgs: &mut Ocgs) -> bool {
|
||||
walk_page(page, ocgs, false, &mut HashMap::new()).hidden_form
|
||||
}
|
||||
|
||||
/// `bytes` with an update appended that drops the catalog's `/OCProperties`, so every group
|
||||
/// draws; `None` when the file's structure can't take one.
|
||||
pub(crate) fn all_on(bytes: &[u8], pdf: &Pdf) -> Option<Vec<u8>> {
|
||||
let root = pdf.xref().root_id();
|
||||
let catalog = pdf.xref().get::<Dict<'_>>(root)?;
|
||||
let body = catalog.data().strip_suffix(b">>")?;
|
||||
let prev = last_xref(bytes)?;
|
||||
let trailer = trailer_at(bytes, prev)?;
|
||||
let inner = trailer.strip_prefix(b"<<")?.strip_suffix(b">>")?;
|
||||
let mut out = bytes.to_vec();
|
||||
if !out.ends_with(b"\n") {
|
||||
out.push(b'\n');
|
||||
}
|
||||
let at = out.len();
|
||||
out.extend(format!("{} {} obj\n", root.obj_number, root.gen_number).bytes());
|
||||
out.extend_from_slice(body);
|
||||
// A later key wins: the groups' configuration is gone, so the interpreter draws them all.
|
||||
out.extend_from_slice(b" /OCProperties null >>\nendobj\n");
|
||||
let xref = out.len();
|
||||
out.extend(format!("xref\n{} 1\n{at:010} {:05} n \ntrailer\n<<", root.obj_number, root.gen_number).bytes());
|
||||
out.extend_from_slice(inner);
|
||||
out.extend(format!(" /Prev {prev} >>\nstartxref\n{xref}\n%%EOF\n").bytes());
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The offset the file's last `startxref` points to.
|
||||
fn last_xref(bytes: &[u8]) -> Option<usize> {
|
||||
let at = bytes.windows(9).rposition(|w| w == b"startxref")?;
|
||||
let rest = bytes.get(at + 9..)?;
|
||||
let digits: String = rest.iter().skip_while(|c| c.is_ascii_whitespace()).take_while(|c| c.is_ascii_digit()).map(|&c| c as char).collect();
|
||||
digits.parse().ok().filter(|&p: &usize| p < bytes.len())
|
||||
}
|
||||
|
||||
/// The trailer dictionary of the cross-reference section at `at` (a table's `trailer`, or a
|
||||
/// cross-reference stream's dictionary), as written.
|
||||
fn trailer_at(bytes: &[u8], at: usize) -> Option<&[u8]> {
|
||||
let rest = bytes.get(at..)?;
|
||||
let from = if rest.trim_ascii_start().starts_with(b"xref") { rest.windows(7).position(|w| w == b"trailer")? } else { 0 };
|
||||
let open = from + rest.get(from..)?.windows(2).position(|w| w == b"<<")?;
|
||||
let d = Dict::from_bytes(rest.get(open..)?)?;
|
||||
Some(d.data())
|
||||
}
|
||||
|
|
@ -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,13 @@
|
|||
//! 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 per page, and a layer per page or per optional content group
|
||||
//! (with its visibility, print state and lock; art that is off comes in as a hidden layer),
|
||||
//! 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 +30,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 +84,29 @@ pub struct ImportOptions {
|
|||
pub crop: CropTo,
|
||||
/// The (user) password of an encrypted PDF.
|
||||
pub password: Option<String>,
|
||||
/// What text becomes.
|
||||
pub text_as: TextAs,
|
||||
/// Optional content groups become layers (else each page is one layer, without the art
|
||||
/// that is off).
|
||||
pub layers: bool,
|
||||
}
|
||||
|
||||
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(), layers: true }
|
||||
}
|
||||
}
|
||||
|
||||
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 +191,10 @@ mod tests_fx;
|
|||
#[cfg(test)]
|
||||
mod tests_import_color;
|
||||
#[cfg(test)]
|
||||
mod tests_import_fidelity;
|
||||
#[cfg(test)]
|
||||
mod tests_import_layers;
|
||||
#[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(_)));
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
//! Optional content groups become layers: name, visibility, print state and lock; art that is
|
||||
//! off comes in as a hidden layer.
|
||||
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_doc::{Document, Node, NodeKind};
|
||||
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with_catalog};
|
||||
|
||||
use crate::tests_import_fidelity::stream;
|
||||
use crate::*;
|
||||
|
||||
/// Groups "Shapes" and "Hidden" (not printed) as the first extra objects of a file of `pages`
|
||||
/// pages, the catalog entries listing them with `config` (where `{A}` and `{B}` stand for them)
|
||||
/// as the default configuration, and their object numbers.
|
||||
fn groups(pages: usize, config: &str) -> ([String; 2], String, [usize; 2]) {
|
||||
let (a, b) = (first_extra(pages), first_extra(pages) + 1);
|
||||
let objs = [
|
||||
"<< /Type /OCG /Name (Shapes) >>".to_string(),
|
||||
"<< /Type /OCG /Name <FEFF00480069006400640065006E> /Usage << /Print << /PrintState /OFF >> >> >>".to_string(),
|
||||
];
|
||||
let catalog = format!(
|
||||
"/OCProperties << /OCGs [{a} 0 R {b} 0 R] /D << /Order [{a} 0 R {b} 0 R] {} >> >> ",
|
||||
config.replace("{A}", &format!("{a} 0 R")).replace("{B}", &format!("{b} 0 R"))
|
||||
);
|
||||
(objs, catalog, [a, b])
|
||||
}
|
||||
|
||||
/// A page drawing red in "Shapes", blue in "Hidden" and green outside any group.
|
||||
fn page(ids: [usize; 2]) -> PdfPage {
|
||||
PdfPage {
|
||||
resources: format!("/Properties << /MC0 {} 0 R /MC1 {} 0 R >>", ids[0], ids[1]),
|
||||
..PdfPage::new(100.0, 100.0, "/OC /MC0 BDC 1 0 0 rg 10 10 30 30 re f EMC /OC /MC1 BDC 0 0 1 rg 50 50 30 30 re f EMC 0 1 0 rg 0 0 5 5 re f")
|
||||
}
|
||||
}
|
||||
|
||||
fn file(config: &str) -> Vec<u8> {
|
||||
let (objs, catalog, ids) = groups(1, config);
|
||||
pdf_with_catalog(&[page(ids)], &[&objs[0], &objs[1]], &catalog, None)
|
||||
}
|
||||
|
||||
fn names(d: &Document) -> Vec<String> {
|
||||
d.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect()
|
||||
}
|
||||
|
||||
/// The fill colours of a layer's art.
|
||||
fn colors(l: &Node) -> Vec<Color> {
|
||||
l.children().unwrap().iter().filter_map(|n| n.appearance.fill().and_then(|f| f.paint.color())).collect()
|
||||
}
|
||||
|
||||
fn printable(l: &Node) -> bool {
|
||||
matches!(l.kind, NodeKind::Layer { printable: true, .. })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_content_groups_become_layers_and_off_art_a_hidden_layer() {
|
||||
let d = import(&file("/OFF [{B}]")).unwrap();
|
||||
// In paint order (bottom first): the groups, then the art outside them.
|
||||
assert_eq!(names(&d), ["Shapes", "Hidden", "Page 1"]);
|
||||
let [shapes, hidden, rest] = [0, 1, 2].map(|i| d.layers[i].clone());
|
||||
assert!(shapes.visible && printable(&shapes) && !shapes.locked);
|
||||
assert_eq!(colors(&shapes), [Color::rgb(1.0, 0.0, 0.0)]);
|
||||
// The group that is off is a hidden layer that still has its art; it doesn't print.
|
||||
assert!(!hidden.visible && !printable(&hidden));
|
||||
assert_eq!(colors(&hidden), [Color::rgb(0.0, 0.0, 1.0)]);
|
||||
assert_eq!(colors(&rest), [Color::rgb(0.0, 1.0, 0.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_state_on_lists_and_locks_are_read() {
|
||||
let d = import(&file("/BaseState /OFF /ON [{A}] /Locked [{A}]")).unwrap();
|
||||
assert_eq!(names(&d), ["Shapes", "Hidden", "Page 1"]);
|
||||
assert!(d.layers[0].visible && d.layers[0].locked);
|
||||
assert!(!d.layers[1].visible && !d.layers[1].locked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_layers_each_page_is_one_layer_of_what_shows() {
|
||||
let r = import_with_report(&file("/OFF [{B}]"), &ImportOptions { layers: false, ..Default::default() }).unwrap();
|
||||
assert_eq!(names(&r.document), ["Page 1"]);
|
||||
assert_eq!(colors(&r.document.layers[0]), [Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 1.0, 0.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_share_the_layers_of_their_groups() {
|
||||
let (objs, catalog, ids) = groups(2, "/OFF [{B}]");
|
||||
let bytes = pdf_with_catalog(&[page(ids), page(ids)], &[&objs[0], &objs[1]], &catalog, None);
|
||||
let d = import(&bytes).unwrap();
|
||||
assert_eq!(names(&d), ["Shapes", "Hidden", "Page 1", "Page 2"]);
|
||||
assert_eq!(colors(&d.layers[0]).len(), 2, "both pages' art");
|
||||
assert_eq!(colors(&d.layers[1]).len(), 2);
|
||||
// The second page's art sits on its artboard.
|
||||
let x = d.layers[0].children().unwrap()[1].geometric_bounds().unwrap().x0;
|
||||
assert!(x > d.artboards[1].rect.x0, "{x}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_marked_inside_forms_and_clips_reach_their_layers() {
|
||||
let (objs, catalog, ids) = groups(1, "/OFF [{B}]");
|
||||
let form_no = first_extra(1) + 2;
|
||||
let form = stream(
|
||||
&format!("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Resources << /Properties << /MC1 {} 0 R >> >>", ids[1]),
|
||||
"/OC /MC1 BDC 0 0 1 rg 50 50 30 30 re f EMC",
|
||||
);
|
||||
let page = PdfPage {
|
||||
resources: format!("/Properties << /MC0 {} 0 R >> /XObject << /X0 {form_no} 0 R >>", ids[0]),
|
||||
..PdfPage::new(100.0, 100.0, "q 0 0 25 25 re W n /OC /MC0 BDC 1 0 0 rg 10 10 30 30 re f EMC Q /X0 Do")
|
||||
};
|
||||
let d = import(&pdf_with_catalog(&[page], &[&objs[0], &objs[1], &form], &catalog, None)).unwrap();
|
||||
assert_eq!(names(&d), ["Shapes", "Hidden"]);
|
||||
let shapes = &d.layers[0].children().unwrap()[0];
|
||||
assert!(matches!(shapes.kind, NodeKind::Group { clip: true, .. }), "the clip group goes with its art");
|
||||
assert!(!d.layers[1].visible);
|
||||
assert_eq!(colors(&d.layers[1]), [Color::rgb(0.0, 0.0, 1.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_form_hidden_by_its_own_group_stays_hidden() {
|
||||
let (objs, catalog, ids) = groups(1, "/OFF [{B}]");
|
||||
let form_no = first_extra(1) + 2;
|
||||
let form = stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /OC {} 0 R", ids[1]), "0 0 1 rg 50 50 30 30 re f");
|
||||
let page = PdfPage { resources: format!("/XObject << /X0 {form_no} 0 R >>"), ..PdfPage::new(100.0, 100.0, "1 0 0 rg 10 10 30 30 re f /X0 Do") };
|
||||
let r = import_with_report(&pdf_with_catalog(&[page], &[&objs[0], &objs[1], &form], &catalog, None), &ImportOptions::default()).unwrap();
|
||||
assert_eq!(names(&r.document), ["Page 1"]);
|
||||
assert_eq!(colors(&r.document.layers[0]), [Color::rgb(1.0, 0.0, 0.0)], "the hidden form isn't shown");
|
||||
assert!(r.warnings.iter().any(|w| w.contains("hidden layers")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_encrypted_file_keeps_its_hidden_layers() {
|
||||
let (objs, catalog, ids) = groups(1, "/OFF [{B}]");
|
||||
let bytes = pdf_with_catalog(&[page(ids)], &[&objs[0], &objs[1]], &catalog, Some("pw"));
|
||||
let r = import_with_report(&bytes, &ImportOptions { password: Some("pw".into()), ..Default::default() }).unwrap();
|
||||
let d = r.document;
|
||||
assert_eq!(d.layers.len(), 3, "{:?}", r.warnings);
|
||||
assert!(d.layers[0].visible && !d.layers[1].visible);
|
||||
assert_eq!(colors(&d.layers[1]), [Color::rgb(0.0, 0.0, 1.0)]);
|
||||
}
|
||||
|
|
@ -65,6 +65,11 @@ pub fn first_extra(pages: usize) -> usize {
|
|||
/// [`pdf`] with `extra` objects (written as given, not encrypted: streams such as functions and
|
||||
/// ICC profiles that resources refer to by number, from [`first_extra`] on).
|
||||
pub fn pdf_with(pages: &[PdfPage], extra: &[&str], password: Option<&str>) -> Vec<u8> {
|
||||
pdf_with_catalog(pages, extra, "", password)
|
||||
}
|
||||
|
||||
/// [`pdf_with`] with more entries in the catalog (`/OCProperties << … >>`).
|
||||
pub fn pdf_with_catalog(pages: &[PdfPage], extra: &[&str], catalog: &str, password: Option<&str>) -> Vec<u8> {
|
||||
let id = md5(b"vectorcraft test file");
|
||||
// (O, U, file key) of the standard security handler, revision 2, permissions -4.
|
||||
let crypt = password.map(|pw| {
|
||||
|
|
@ -80,7 +85,7 @@ pub fn pdf_with(pages: &[PdfPage], extra: &[&str], password: Option<&str>) -> Ve
|
|||
});
|
||||
let mut objects: Vec<Vec<u8>> = vec![];
|
||||
let kids: Vec<String> = (0..pages.len()).map(|i| format!("{} 0 R", 3 + 2 * i)).collect();
|
||||
objects.push(b"<< /Type /Catalog /Pages 2 0 R >>".to_vec());
|
||||
objects.push(format!("<< /Type /Catalog /Pages 2 0 R {catalog}>>").into_bytes());
|
||||
objects.push(format!("<< /Type /Pages /Kids [{}] /Count {} >>", kids.join(" "), pages.len()).into_bytes());
|
||||
for (i, p) in pages.iter().enumerate() {
|
||||
let mut d = format!("<< /Type /Page /Parent 2 0 R /MediaBox {} /Contents {} 0 R /Resources << {} >>", rect(p.media), 4 + 2 * i, p.resources);
|
||||
|
|
|
|||
13
docs/mcp.md
13
docs/mcp.md
|
|
@ -235,6 +235,19 @@ 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. Optional content groups (the layers of PDF and PDF-compatible `.ai`
|
||||
files) become layers with their name, visibility, print state and lock, art that is off coming in as a hidden layer;
|
||||
art outside them goes to a layer per page. `layers: false` gives one layer per page of only what shows:
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"document.open","params":{"path":"/tmp/map.pdf","textAs":"outlines","layers":false}}}
|
||||
```
|
||||
|
||||
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