Merge pull request #531 from storytold/fix/issue-508
Fix #508: PDF/.ai import keeps type strokes, wrapped lines and layer order
This commit is contained in:
commit
89ea46c805
File diff suppressed because one or more lines are too long
|
|
@ -78,7 +78,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 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, grays? as there; 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), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). 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, lines wrapped in a frame as area type where they lay out the same, 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, grays? as there; 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), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). 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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -488,6 +488,10 @@ fn arb_rich_op() -> impl Strategy<Value = String> {
|
|||
"/Sh0 sh",
|
||||
"/Sh1 sh",
|
||||
"BT /F1 12 Tf 10 10 Td (Hi there) Tj [(a) -900 (b)] TJ ET",
|
||||
// Lines wrapped in a frame, digits set apart by gaps, glyph outlines stroked (#508).
|
||||
"BT /F1 10 Tf 10 80 Td (Wrapped line ) Tj 0 -12 Td (and its next ) Tj 0 -12 Td (end.) Tj ET",
|
||||
"BT /F1 10 Tf 10 40 Td (3) Tj 20 0 Td (5331) Tj 40 0 Td (0106) Tj ET",
|
||||
"0.5 w 10 10 m 14 18 l 18 10 l h S",
|
||||
"7 Tr",
|
||||
"q 10 10 50 50 re W n",
|
||||
"Q",
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ use crate::import_color::{Colors, Native};
|
|||
use crate::import_mask::{MaskSpec, contains, is_rectangle, luminance, mask_spec, white_cover};
|
||||
use crate::import_scan::{MAX_NESTING, Ocgs, Scan, all_on, hides_forms, scan_page, tag_key};
|
||||
use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, mesh_shading, shading_gradient};
|
||||
use crate::import_text::{Families, Look, Placement, TextLine, Upright};
|
||||
use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, Upright};
|
||||
use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs};
|
||||
|
||||
/// The name of the paths text imports as.
|
||||
|
|
@ -76,6 +76,33 @@ fn place_group(g: usize, ocgs: &Ocgs, slots: &mut Vec<Slot>, nesting: &mut Nesti
|
|||
slots.push(Slot::Group(child));
|
||||
}
|
||||
|
||||
/// The top-level group that group `g` is listed under (`g` itself when it isn't a sublayer).
|
||||
fn top_group(g: usize, ocgs: &Ocgs) -> usize {
|
||||
let mut top = g;
|
||||
for _ in 0..=MAX_NESTING {
|
||||
match ocgs.list.get(top).and_then(|o| o.parent) {
|
||||
Some(parent) => top = parent,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
top
|
||||
}
|
||||
|
||||
/// Merge the top-level groups a page marks (`page`, in the order it first marks each, its empty
|
||||
/// ones too) into the stacking order of the pages before (`order`, bottom first; `known`: its
|
||||
/// groups). A group new to it goes right below the first group the page marks after it that it
|
||||
/// has: a layer whose art starts on a later page keeps its place among the others (#508).
|
||||
fn merge_order(order: &mut Vec<usize>, known: &mut HashSet<usize>, page: &[usize]) {
|
||||
let mut above = None;
|
||||
for &g in page.iter().rev() {
|
||||
if known.insert(g) {
|
||||
let at = above.and_then(|a| order.iter().position(|&x| x == a)).unwrap_or(order.len());
|
||||
order.insert(at, g);
|
||||
}
|
||||
above = Some(g);
|
||||
}
|
||||
}
|
||||
|
||||
/// The layer of group `g`, in `color`: its art and sublayers. `None` for a group that isn't read.
|
||||
fn group_layer(
|
||||
b: &mut Builder<'_>,
|
||||
|
|
@ -166,6 +193,8 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
|||
let mut slots: Vec<Slot> = vec![];
|
||||
let mut group_art: HashMap<usize, Vec<Arc<Node>>> = HashMap::new();
|
||||
let mut nesting = Nesting::new();
|
||||
// The top-level groups in the order the pages mark them, bottom first.
|
||||
let (mut order, mut known) = (vec![], HashSet::new());
|
||||
for (i, &number) in picked.iter().enumerate() {
|
||||
let Some(page) = pages.get(number) else { continue };
|
||||
// The chosen box sits at (x, 0); the page draws round it.
|
||||
|
|
@ -175,6 +204,11 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
|||
let mut ctx = Context::new(xf, ab, &cache, pdf.xref(), settings.clone());
|
||||
b.page = ab;
|
||||
let scan = scan_page(page, &mut ocgs, all_on, &mut b.fonts);
|
||||
if route {
|
||||
let mut seen = HashSet::new();
|
||||
let marked: Vec<usize> = scan.tags.iter().filter_map(|(_, g)| g.map(|g| top_group(g, &ocgs))).filter(|g| seen.insert(*g)).collect();
|
||||
merge_order(&mut order, &mut known, &marked);
|
||||
}
|
||||
b.begin_page(scan);
|
||||
interpret_page(page, &mut ctx, &mut b);
|
||||
let mut parts = b.end_page();
|
||||
|
|
@ -239,8 +273,25 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
|||
if placeholder {
|
||||
return Err(PdfError::PlaceholderOnly);
|
||||
}
|
||||
// A group the pages mark but draw nothing in is an empty layer, as the editor had it.
|
||||
for &g in &order {
|
||||
if !nesting.contains_key(&g) {
|
||||
slots.push(Slot::Group(g));
|
||||
}
|
||||
}
|
||||
// The groups' layers stack as the pages mark them (the art outside them stays where it was
|
||||
// drawn): a group that first paints on a later page doesn't go on top of the others.
|
||||
let rank: HashMap<usize, usize> = order.iter().enumerate().map(|(i, &g)| (g, i)).collect();
|
||||
let at: Vec<usize> = slots.iter().enumerate().filter(|(_, s)| matches!(s, Slot::Group(_))).map(|(i, _)| i).collect();
|
||||
let mut stacked: Vec<usize> = slots.iter().filter_map(|s| if let Slot::Group(g) = s { Some(*g) } else { None }).collect();
|
||||
stacked.sort_by_key(|g| rank.get(g).copied().unwrap_or(usize::MAX));
|
||||
for (i, g) in at.into_iter().zip(stacked) {
|
||||
if let Some(s) = slots.get_mut(i) {
|
||||
*s = Slot::Group(g);
|
||||
}
|
||||
}
|
||||
for (n, slot) in slots.into_iter().enumerate() {
|
||||
let layer = match slot {
|
||||
let mut layer = match slot {
|
||||
Slot::Page(l) => *l,
|
||||
// Sublayers take their top-level layer's colour.
|
||||
Slot::Group(g) => match group_layer(&mut b, g, &ocgs, &nesting, &mut group_art, LayerColor::Preset((n % 27) as u8)) {
|
||||
|
|
@ -248,6 +299,9 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
|||
None => continue,
|
||||
},
|
||||
};
|
||||
if let Some(art) = layer.children_mut() {
|
||||
crate::import_lines::rebuild(art, &b.lines);
|
||||
}
|
||||
doc.layers.push(Arc::new(layer));
|
||||
}
|
||||
for (k, blob) in b.images.drain() {
|
||||
|
|
@ -567,6 +621,8 @@ struct Builder<'p> {
|
|||
nested: u32,
|
||||
/// The last filled path: its node and its outline (a stroke of the same outline joins it).
|
||||
last_fill: Option<(NodeId, BezPath)>,
|
||||
/// What each line of type made tells beyond its text object ([`crate::import_lines`]).
|
||||
lines: HashMap<NodeId, LineFacts>,
|
||||
}
|
||||
|
||||
fn blend(b: hayro_interpret::BlendMode) -> BlendMode {
|
||||
|
|
@ -701,6 +757,7 @@ impl<'p> Builder<'p> {
|
|||
pending: false,
|
||||
nested: 0,
|
||||
last_fill: None,
|
||||
lines: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -786,10 +843,11 @@ impl<'p> Builder<'p> {
|
|||
let current = self.current_group();
|
||||
let Some(f) = self.stack.last_mut() else { return };
|
||||
f.children.push(n);
|
||||
// A frame's art goes where its first art was drawn (a clip can end in a later group).
|
||||
if root {
|
||||
self.root_groups.push(group.or(current));
|
||||
} else if f.group.is_none() {
|
||||
f.group = group;
|
||||
f.group = group.or(current);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -955,9 +1013,10 @@ impl<'p> Builder<'p> {
|
|||
}
|
||||
|
||||
fn flush_text(&mut self) {
|
||||
let Some((t, opacity)) = self.text.take().and_then(TextLine::finish) else { return };
|
||||
let Some((t, opacity, facts)) = self.text.take().and_then(TextLine::finish) else { return };
|
||||
let mut n = Node::new(self.id(), NodeKind::Text(Box::new(t)));
|
||||
n.opacity = opacity;
|
||||
self.lines.insert(n.id, facts);
|
||||
self.push_node(n);
|
||||
}
|
||||
|
||||
|
|
@ -1227,6 +1286,14 @@ impl<'p> Builder<'p> {
|
|||
place(&mut line);
|
||||
self.text = Some(line);
|
||||
}
|
||||
// Its ink: a stroke the file draws as outlines of the glyphs is told by it.
|
||||
let mut ink = o.outline();
|
||||
if let Some(line) = &mut self.text
|
||||
&& !ink.elements().is_empty()
|
||||
{
|
||||
ink.apply_affine(m);
|
||||
line.ink.push(ink.bounding_box());
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
|
@ -1570,8 +1637,10 @@ impl<'a> Device<'a> for Builder<'_> {
|
|||
if self.nested > 0 {
|
||||
return;
|
||||
}
|
||||
if self.marked.pop().flatten().is_some() {
|
||||
// The runs gathered within a layer's sequence are its art: finish them before it ends.
|
||||
if self.marked.last().copied().flatten().is_some() {
|
||||
self.flush();
|
||||
}
|
||||
self.marked.pop();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,254 @@
|
|||
//! What a file's lines of type were, beyond what each line says on its own (#508). An editor
|
||||
//! writes what PDF text can't say as more art next to the text, and the type it wrapped in a
|
||||
//! frame as lines:
|
||||
//! - a stroke added to a type object (above or below its characters) comes as the outline of each
|
||||
//! glyph stroked, one path per glyph, right after the type (or right before it): it is the
|
||||
//! type's stroke again, so the type stays one object;
|
||||
//! - area type comes as a line of point type per line, each wrapped line ending in a space. Lines
|
||||
//! in a row sharing a left edge, font, size and leading (and paragraph spacing) are one area
|
||||
//! type object again, aligned left or justified, where laying its text out in the frame breaks
|
||||
//! it where the file does: else, as where its font isn't installed, they stay lines.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::{Point, Rect, Shape};
|
||||
use vectorcraft_color::BlendMode;
|
||||
use vectorcraft_doc::{AppearanceItem, Justify, Node, NodeId, NodeKind, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_geom::shapes;
|
||||
|
||||
use crate::import_text::LineFacts;
|
||||
|
||||
/// Rebuild the type among `nodes` (and in their groups) from what its lines tell (`lines`).
|
||||
pub(crate) fn rebuild(nodes: &mut Vec<Arc<Node>>, lines: &HashMap<NodeId, LineFacts>) {
|
||||
if lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
for n in nodes.iter_mut() {
|
||||
if n.children().is_some_and(|c| !c.is_empty())
|
||||
&& let Some(children) = Arc::make_mut(n).children_mut()
|
||||
{
|
||||
rebuild(children, lines);
|
||||
}
|
||||
}
|
||||
join_strokes(nodes, lines);
|
||||
join_paragraphs(nodes, lines);
|
||||
}
|
||||
|
||||
/// The stroke paths `nodes` draw when they are the outlines of the glyphs whose ink boxes are
|
||||
/// `ink`, stroked one by one alike (no fill, mask or blending, fully opaque).
|
||||
fn outline_stroke(nodes: &[Arc<Node>], ink: &[Rect]) -> Option<AppearanceItem> {
|
||||
let first = nodes.first()?;
|
||||
let [stroke @ AppearanceItem::Stroke(_)] = first.appearance.items.as_slice() else { return None };
|
||||
let on_glyph = |n: &Node, r: &Rect| {
|
||||
let tol = 0.01 * (r.width() + r.height()) + 0.01;
|
||||
let NodeKind::Path { path, clipping: false, guide: false, live: None, .. } = &n.kind else { return false };
|
||||
let b = path.to_bezpath().bounding_box();
|
||||
n.appearance == first.appearance
|
||||
&& n.mask.is_none()
|
||||
&& n.opacity >= 0.999
|
||||
&& n.blend == BlendMode::Normal
|
||||
&& [(b.x0, r.x0), (b.y0, r.y0), (b.x1, r.x1), (b.y1, r.y1)].iter().all(|(a, b)| (a - b).abs() <= tol)
|
||||
};
|
||||
(nodes.len() == ink.len() && nodes.iter().zip(ink).all(|(n, r)| on_glyph(n, r))).then(|| stroke.clone())
|
||||
}
|
||||
|
||||
/// Each type object among `nodes` whose glyphs' outlines are stroked by the paths right after it
|
||||
/// (or right before it) takes that stroke above (or below) its characters, and the paths go.
|
||||
fn join_strokes(nodes: &mut Vec<Arc<Node>>, lines: &HashMap<NodeId, LineFacts>) {
|
||||
let mut i = 0;
|
||||
while i < nodes.len() {
|
||||
let ink = nodes
|
||||
.get(i)
|
||||
.filter(|n| matches!(n.kind, NodeKind::Text(_)) && n.appearance.items.is_empty())
|
||||
.and_then(|n| lines.get(&n.id))
|
||||
.map_or(&[][..], |f| f.ink.as_slice());
|
||||
let k = ink.len();
|
||||
if k > 0 {
|
||||
if let Some(stroke) = nodes.get(i + 1..i + 1 + k).and_then(|after| outline_stroke(after, ink)) {
|
||||
nodes.drain(i + 1..i + 1 + k);
|
||||
if let Some(t) = nodes.get_mut(i) {
|
||||
Arc::make_mut(t).appearance.items.push(stroke);
|
||||
}
|
||||
} else if let Some(stroke) = i.checked_sub(k).and_then(|from| nodes.get(from..i)).and_then(|before| outline_stroke(before, ink)) {
|
||||
nodes.drain(i - k..i);
|
||||
i -= k;
|
||||
if let Some(t) = nodes.get_mut(i) {
|
||||
let ap = &mut Arc::make_mut(t).appearance;
|
||||
ap.items.insert(0, stroke);
|
||||
ap.set_contents_at(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` as a line that can be part of area type: horizontal point type, with what its line told.
|
||||
fn line<'a>(n: &'a Node, lines: &'a HashMap<NodeId, LineFacts>) -> Option<(&'a TextObject, &'a LineFacts)> {
|
||||
let NodeKind::Text(t) = &n.kind else { return None };
|
||||
let point = matches!(t.kind, TextKind::Point) && !t.vertical && n.mask.is_none() && !t.runs.is_empty();
|
||||
point.then_some(())?;
|
||||
Some((t, lines.get(&n.id)?))
|
||||
}
|
||||
|
||||
/// Lines of type in a row that may be one area type object.
|
||||
struct Block {
|
||||
/// The step from a wrapped line's baseline to the next.
|
||||
leading: f64,
|
||||
/// The extra step after a line ending a paragraph.
|
||||
space: f64,
|
||||
/// Each line's baseline below the first (text space).
|
||||
ys: Vec<f64>,
|
||||
}
|
||||
|
||||
/// The lines from `nodes`' first on that share its left edge, angle, font and size, its look
|
||||
/// (opacity, blending, appearance) and steps down by one leading after a wrapped line and by
|
||||
/// another (the leading and paragraph spacing) after a line ending a paragraph: how many, and
|
||||
/// the block they make unless none of them was wrapped (0 and `None`: the first isn't a line).
|
||||
fn block(nodes: &[Arc<Node>], lines: &HashMap<NodeId, LineFacts>) -> (usize, Option<Block>) {
|
||||
let Some((head, (t0, f0))) = nodes.first().and_then(|n| Some((n, line(n, lines)?))) else { return (0, None) };
|
||||
let st = t0.first_style();
|
||||
let size = st.size;
|
||||
let inv = t0.xf.inverse();
|
||||
let lin = |t: &TextObject| {
|
||||
let [a, b, c, d, ..] = t.xf.as_coeffs();
|
||||
[a, b, c, d]
|
||||
};
|
||||
let (mut ys, mut wraps) = (vec![0.0], f0.wraps);
|
||||
let (mut leading, mut para): (Option<f64>, Option<f64>) = (None, None);
|
||||
for n in nodes.iter().skip(1) {
|
||||
let Some((t, f)) = line(n, lines) else { break };
|
||||
let s = t.first_style();
|
||||
let alike = n.opacity == head.opacity
|
||||
&& n.blend == head.blend
|
||||
&& n.appearance == head.appearance
|
||||
&& s.font_family == st.font_family
|
||||
&& s.font_style == st.font_style
|
||||
&& (s.size - size).abs() <= size * 0.01
|
||||
&& t.para == t0.para
|
||||
&& lin(t).iter().zip(lin(t0)).all(|(a, b)| (a - b).abs() < 1e-6);
|
||||
let at = inv * (t.xf * Point::ZERO);
|
||||
let step = at.y - ys.last().copied().unwrap_or_default();
|
||||
if !alike || at.x.abs() > size * 0.02 || !(step > size * 0.5 && step < size * 4.0) {
|
||||
break;
|
||||
}
|
||||
let slot = if wraps { &mut leading } else { &mut para };
|
||||
match *slot {
|
||||
Some(s) if (s - step).abs() > size * 0.01 => break,
|
||||
Some(_) => {}
|
||||
None => *slot = Some(step),
|
||||
}
|
||||
ys.push(at.y);
|
||||
wraps = f.wraps;
|
||||
}
|
||||
let count = ys.len();
|
||||
let Some(leading) = leading else { return (count, None) };
|
||||
let space = para.map_or(0.0, |p| p - leading);
|
||||
(count, (space > -size * 0.01).then_some(Block { leading, space: space.max(0.0), ys }))
|
||||
}
|
||||
|
||||
/// Lines `nodes` of block `b` as one area type object, aligned left or justified, when laying it
|
||||
/// out breaks its lines where the file does.
|
||||
fn area_type(nodes: &[Arc<Node>], lines: &HashMap<NodeId, LineFacts>, b: &Block) -> Option<TextObject> {
|
||||
let texts: Vec<(&TextObject, &LineFacts)> = nodes.iter().map(|n| line(n, lines)).collect::<Option<_>>()?;
|
||||
let (first, _) = texts.first()?;
|
||||
let size = first.first_style().size;
|
||||
// Each line's tracking (a line with spaces tracked apart was set by hand).
|
||||
let trackings: Vec<f64> = texts
|
||||
.iter()
|
||||
.map(|(t, _)| {
|
||||
let mut all = t.runs.iter().map(|r| r.style.tracking);
|
||||
let one = all.next()?;
|
||||
all.all(|x| x == one).then_some(one)
|
||||
})
|
||||
.collect::<Option<_>>()?;
|
||||
let median = |mut v: Vec<f64>| {
|
||||
v.sort_by(f64::total_cmp);
|
||||
v.get(v.len() / 2).copied()
|
||||
};
|
||||
let wrapped: Vec<f64> = texts.iter().filter(|(_, f)| f.wraps).map(|(_, f)| f.length).collect();
|
||||
let longest = |v: &[f64]| v.iter().copied().fold(0.0, f64::max);
|
||||
let mut tries = vec![];
|
||||
// Wrapped lines all as long: justified (a paragraph's last line aligned left), tracked as the
|
||||
// lines that end paragraphs are (the others are spaced out).
|
||||
let full = longest(&wrapped);
|
||||
if wrapped.len() >= 2 && wrapped.iter().all(|l| (full - l).abs() <= size * 0.02) {
|
||||
let ends: Vec<f64> = texts.iter().zip(&trackings).filter(|((_, f), _)| !f.wraps).map(|(_, t)| *t).collect();
|
||||
let tracking = median(ends).or_else(|| trackings.iter().copied().reduce(f64::min))?;
|
||||
tries.push((Justify::JustifyLeft, full, tracking));
|
||||
}
|
||||
let all: Vec<f64> = texts.iter().map(|(_, f)| f.length).collect();
|
||||
tries.push((Justify::Left, longest(&all) + size * 0.02, median(trackings)?));
|
||||
tries.into_iter().find_map(|(justify, width, tracking)| laid_out(&texts, b, justify, width, tracking))
|
||||
}
|
||||
|
||||
/// The area type of lines `texts` (block `b`): `width` wide, aligned `justify`, its characters
|
||||
/// tracked `tracking`; `None` when it doesn't lay out as the lines did.
|
||||
fn laid_out(texts: &[(&TextObject, &LineFacts)], b: &Block, justify: Justify, width: f64, tracking: f64) -> Option<TextObject> {
|
||||
let (first, _) = texts.first()?;
|
||||
let size = first.first_style().size;
|
||||
// Leading as set, unless it is Auto's (120% of the size).
|
||||
let explicit =
|
||||
(b.leading - size * 1.2).abs() > size * 1e-3 || texts.iter().any(|(t, _)| t.runs.iter().any(|r| (r.style.size - size).abs() > 1e-9));
|
||||
let mut runs: Vec<TextRun> = vec![];
|
||||
for (k, (t, f)) in texts.iter().enumerate() {
|
||||
for r in &t.runs {
|
||||
let mut style = r.style.clone();
|
||||
style.tracking = tracking;
|
||||
style.leading = explicit.then_some((b.leading * 1000.0).round() / 1000.0);
|
||||
match runs.last_mut() {
|
||||
Some(last) if last.style == style => last.text.push_str(&r.text),
|
||||
_ => runs.push(TextRun { text: r.text.clone(), style }),
|
||||
}
|
||||
}
|
||||
// A wrapped line's space, or the end of a paragraph.
|
||||
if k + 1 < texts.len()
|
||||
&& let Some(last) = runs.last_mut()
|
||||
{
|
||||
last.text.push(if f.wraps { ' ' } else { '\n' });
|
||||
}
|
||||
}
|
||||
let mut t = (*first).clone();
|
||||
t.runs = runs;
|
||||
t.para.justify = justify;
|
||||
t.para.space_before = b.space;
|
||||
let last = b.ys.last().copied()?;
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
// The frame's top puts the first baseline where the file has it.
|
||||
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, width, last + b.leading.max(size) + size)) };
|
||||
let top = -vectorcraft_text::layout(db, &t).lines.first()?.baseline;
|
||||
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, top, width, last + b.leading.max(size))) };
|
||||
let laid = vectorcraft_text::layout(db, &t);
|
||||
let plain = t.plain_text();
|
||||
let same = !laid.overflow
|
||||
&& laid.lines.len() == texts.len()
|
||||
&& laid.lines.iter().zip(texts).zip(&b.ys).all(|((l, (line, f)), y)| {
|
||||
let text = plain.get(l.start..l.end).unwrap_or_default();
|
||||
text.trim() == line.plain_text().trim()
|
||||
&& (l.baseline - y).abs() <= size * 0.05
|
||||
&& (justify != Justify::JustifyLeft || !f.wraps || (l.x1 - f.length).abs() <= size * 0.05)
|
||||
});
|
||||
t.cached_bounds = Some(laid.bounds);
|
||||
same.then_some(t)
|
||||
}
|
||||
|
||||
/// Lines of point type in a row among `nodes` that were wrapped in a frame become one area type
|
||||
/// object again (see [`area_type`]).
|
||||
fn join_paragraphs(nodes: &mut Vec<Arc<Node>>, lines: &HashMap<NodeId, LineFacts>) {
|
||||
let mut i = 0;
|
||||
while i < nodes.len() {
|
||||
let (count, b) = nodes.get(i..).map_or((0, None), |rest| block(rest, lines));
|
||||
let area = b.filter(|_| count > 1).and_then(|b| area_type(nodes.get(i..i + count)?, lines, &b));
|
||||
if let Some(area) = area
|
||||
&& let Some(head) = nodes.get_mut(i)
|
||||
{
|
||||
Arc::make_mut(head).kind = NodeKind::Text(Box::new(area));
|
||||
nodes.drain(i + 1..i + count);
|
||||
i += 1;
|
||||
} else {
|
||||
// Lines that don't make area type: none of them starts it either.
|
||||
i += count.max(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,9 +9,10 @@
|
|||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use kurbo::{Affine, BezPath, Point, Vec2};
|
||||
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{CharStyle, ParaDirection, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_text::TextLayout;
|
||||
|
||||
/// One glyph's placement: its baseline origin, advance direction, size and horizontal scale.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
|
|
@ -103,6 +104,8 @@ pub(crate) struct TextLine {
|
|||
next: Point,
|
||||
/// The last glyph's origin.
|
||||
last: Point,
|
||||
/// Where the last glyph that isn't a space ends.
|
||||
visible: Point,
|
||||
/// The last glyph's baseline direction.
|
||||
last_dir: Vec2,
|
||||
/// Each glyph's baseline origin, then where the last one ends.
|
||||
|
|
@ -112,6 +115,24 @@ pub(crate) struct TextLine {
|
|||
upright: Option<Upright>,
|
||||
/// The glyphs make a column (vertical type), each below the last.
|
||||
column: bool,
|
||||
/// Each glyph's ink box as drawn (document space), in drawing order; glyphs without ink are
|
||||
/// left out.
|
||||
pub ink: Vec<Rect>,
|
||||
/// The spaces read from gaps between glyphs: their byte offset in the text, and the gap from
|
||||
/// the end of the glyph before to the start of the next (points).
|
||||
gaps: Vec<(usize, f64)>,
|
||||
}
|
||||
|
||||
/// What a finished line of type tells beyond its text object (see [`crate::import_lines`]).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct LineFacts {
|
||||
/// [`TextLine::ink`].
|
||||
pub ink: Vec<Rect>,
|
||||
/// Horizontal point type: it ended in a space (as a line an editor wrapped in a frame does).
|
||||
pub wraps: bool,
|
||||
/// Horizontal point type: how long the file set it, from its first glyph's origin to the end
|
||||
/// of its last glyph that isn't a space (points, along the baseline).
|
||||
pub length: f64,
|
||||
}
|
||||
|
||||
/// The most a glyph on a curve turns from the one before it (about 20°).
|
||||
|
|
@ -119,7 +140,20 @@ const CURVE_TURN_COS: f64 = 0.94;
|
|||
|
||||
impl TextLine {
|
||||
pub fn new(at: Placement, opacity: f32) -> Self {
|
||||
Self { at, opacity, next: at.origin, last: at.origin, last_dir: at.dir, baseline: vec![], runs: vec![], upright: None, column: false }
|
||||
Self {
|
||||
at,
|
||||
opacity,
|
||||
next: at.origin,
|
||||
last: at.origin,
|
||||
visible: at.origin,
|
||||
last_dir: at.dir,
|
||||
baseline: vec![],
|
||||
runs: vec![],
|
||||
upright: None,
|
||||
column: false,
|
||||
ink: vec![],
|
||||
gaps: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Add glyph `text` set vertically (`upright`) if it continues this column (or makes this
|
||||
|
|
@ -171,6 +205,7 @@ impl TextLine {
|
|||
if self.column || self.upright.is_some() {
|
||||
return false;
|
||||
}
|
||||
let bytes: usize = self.runs.iter().map(|(_, t)| t.len()).sum();
|
||||
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.last_dir);
|
||||
|
|
@ -192,6 +227,9 @@ impl TextLine {
|
|||
// 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 on_line {
|
||||
self.gaps.push((bytes, gap));
|
||||
}
|
||||
}
|
||||
if last.takes(look) {
|
||||
run.push_str(text);
|
||||
|
|
@ -202,6 +240,9 @@ impl TextLine {
|
|||
self.runs.push((look.clone(), text.to_string()));
|
||||
}
|
||||
self.next = at.origin + at.dir * advance;
|
||||
if !text.trim().is_empty() {
|
||||
self.visible = self.next;
|
||||
}
|
||||
self.last = at.origin;
|
||||
self.last_dir = at.dir;
|
||||
self.baseline.push(at.origin);
|
||||
|
|
@ -247,10 +288,12 @@ impl TextLine {
|
|||
true
|
||||
}
|
||||
|
||||
/// The point type object and its opacity.
|
||||
pub fn finish(mut self) -> Option<(TextObject, f32)> {
|
||||
/// The point type object, its opacity and what else the line tells.
|
||||
pub fn finish(mut self) -> Option<(TextObject, f32, LineFacts)> {
|
||||
let mut facts = LineFacts { ink: std::mem::take(&mut self.ink), ..LineFacts::default() };
|
||||
// Turned along a curve: type on a path through the glyphs.
|
||||
let path = if self.curved() { self.baseline_path() } else { None };
|
||||
facts.wraps = path.is_none() && self.upright.is_none() && self.runs.last().is_some_and(|(_, t)| t.ends_with(' '));
|
||||
if let Some((_, last)) = self.runs.last_mut() {
|
||||
last.truncate(last.trim_end().len());
|
||||
}
|
||||
|
|
@ -272,7 +315,7 @@ impl TextLine {
|
|||
t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&path), start: 0.0, end: None };
|
||||
t.xf = Affine::IDENTITY;
|
||||
t.cached_bounds = Some(vectorcraft_text::layout(db, &t).bounds);
|
||||
return Some((t, self.opacity));
|
||||
return Some((t, self.opacity, facts));
|
||||
}
|
||||
let (start, along) = match self.upright {
|
||||
// Vertical point type (a column, or a single upright glyph) is anchored at the top
|
||||
|
|
@ -286,26 +329,87 @@ impl TextLine {
|
|||
};
|
||||
// Tracking that makes the line as long as in the file: the PDF placed each glyph, the
|
||||
// layout sets them by their advances. Spread over the gaps between characters (tracking
|
||||
// follows each character, the last one's past the end of the line).
|
||||
let length = (self.next - start).dot(along);
|
||||
// follows each character, the last one's past the end of the line). A horizontal line is
|
||||
// measured to its last glyph that isn't a space: the spaces after it were dropped.
|
||||
let length = (if t.vertical { self.next } else { self.visible } - start).dot(along);
|
||||
if !t.vertical {
|
||||
facts.length = length;
|
||||
}
|
||||
let laid = vectorcraft_text::layout(db, &t);
|
||||
let natural: f64 = laid.glyphs.iter().map(|g| g.advance).sum();
|
||||
let chars: usize = t.runs.iter().map(|r| r.text.chars().count()).sum();
|
||||
let size = self.at.size;
|
||||
let mut bounds = laid.bounds;
|
||||
if chars > 1 && length.is_finite() && (length - natural).abs() > size * 0.01 {
|
||||
let tracking = ((length - natural) / (chars - 1) as f64 / size * 1000.0).clamp(-1000.0, 1000.0).round();
|
||||
for r in &mut t.runs {
|
||||
r.style.tracking = tracking;
|
||||
}
|
||||
// The spaces read from gaps keep their own place only in text left in the file's order.
|
||||
let total: usize = t.runs.iter().map(|r| r.text.len()).sum();
|
||||
let gaps: Vec<(usize, f64)> =
|
||||
if t.para.direction.is_none() { self.gaps.into_iter().filter(|(b, _)| *b < total).collect() } else { vec![] };
|
||||
let (tracking, spaces) = spacing(&laid, &gaps, length - natural, chars, size);
|
||||
t.runs = tracked(std::mem::take(&mut t.runs), tracking, &spaces);
|
||||
// Tracked: laid out again for its bounds.
|
||||
bounds = vectorcraft_text::layout(db, &t).bounds;
|
||||
}
|
||||
t.cached_bounds = Some(bounds);
|
||||
Some((t, self.opacity))
|
||||
Some((t, self.opacity, facts))
|
||||
}
|
||||
}
|
||||
|
||||
/// The tracking (thousandths of an em) that sets a line of `chars` characters, laid out untracked
|
||||
/// as `laid`, `extra` points longer, as the file set it: the same after every character, except
|
||||
/// the spaces read from gaps (`gaps`: byte offset, gap in points) where that would put the glyphs
|
||||
/// after them more than a tenth of an em off (a barcode's digits, set in groups apart). Those take
|
||||
/// their own tracking, given with their byte offsets.
|
||||
fn spacing(laid: &TextLayout, gaps: &[(usize, f64)], extra: f64, chars: usize, size: f64) -> (f64, Vec<(usize, f64)>) {
|
||||
let pairs = chars.saturating_sub(1).max(1) as f64;
|
||||
// The space and the character before it take `need` (the gap less the space) between them;
|
||||
// tracking gives them twice its share.
|
||||
let space = |b: usize| laid.glyphs.iter().find(|g| g.byte == b).map(|g| g.advance);
|
||||
let needs: Vec<(usize, f64)> = gaps.iter().filter_map(|&(b, gap)| Some((b, gap - space(b)?))).collect();
|
||||
let mut own: Vec<(usize, f64)> = vec![];
|
||||
let mut tracking = extra / pairs;
|
||||
// Each space taken apart changes the others' share: until no more are.
|
||||
loop {
|
||||
let more: Vec<(usize, f64)> =
|
||||
needs.iter().filter(|(b, need)| !own.iter().any(|(o, _)| o == b) && (need - 2.0 * tracking).abs() > size * 0.1).copied().collect();
|
||||
if more.is_empty() {
|
||||
break;
|
||||
}
|
||||
own.extend(more);
|
||||
let rest = pairs - 2.0 * own.len() as f64;
|
||||
tracking = if rest >= 1.0 { (extra - own.iter().map(|(_, need)| need).sum::<f64>()) / rest } else { 0.0 };
|
||||
}
|
||||
own.sort_by_key(|(b, _)| *b);
|
||||
let em = |pts: f64| (pts / size * 1000.0).clamp(-1000.0, 1000.0).round();
|
||||
(em(tracking), own.into_iter().map(|(b, need)| (b, em(need - tracking))).collect())
|
||||
}
|
||||
|
||||
/// `runs` with tracking `tracking`, and each space at a byte offset of `spaces` (in order) a run
|
||||
/// of its own with the tracking given with it.
|
||||
fn tracked(runs: Vec<TextRun>, tracking: f64, spaces: &[(usize, f64)]) -> Vec<TextRun> {
|
||||
let mut out = Vec::with_capacity(runs.len() + 2 * spaces.len());
|
||||
let mut off = 0;
|
||||
for r in runs {
|
||||
let style = CharStyle { tracking, ..r.style };
|
||||
let end = off + r.text.len();
|
||||
let mut from = 0;
|
||||
for &(b, own) in spaces.iter().filter(|(b, _)| (off..end).contains(b)) {
|
||||
let at = b - off;
|
||||
let (Some(before), Some(space)) = (r.text.get(from..at), r.text.get(at..at + 1)) else { continue };
|
||||
if !before.is_empty() {
|
||||
out.push(TextRun { text: before.to_string(), style: style.clone() });
|
||||
}
|
||||
out.push(TextRun { text: space.to_string(), style: CharStyle { tracking: own, ..style.clone() } });
|
||||
from = at + 1;
|
||||
}
|
||||
if let Some(rest) = r.text.get(from..).filter(|t| !t.is_empty()) {
|
||||
out.push(TextRun { text: rest.to_string(), style });
|
||||
}
|
||||
off = end;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn round(v: f64) -> f64 {
|
||||
(v * 1000.0).round() / 1000.0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ mod images;
|
|||
mod import;
|
||||
mod import_color;
|
||||
mod import_image;
|
||||
mod import_lines;
|
||||
mod import_mask;
|
||||
mod import_scan;
|
||||
mod import_shading;
|
||||
|
|
@ -231,6 +232,8 @@ mod tests_import_fidelity;
|
|||
#[cfg(test)]
|
||||
mod tests_import_layers;
|
||||
#[cfg(test)]
|
||||
mod tests_import_lines;
|
||||
#[cfg(test)]
|
||||
mod tests_import_options;
|
||||
#[cfg(test)]
|
||||
mod tests_layers;
|
||||
|
|
|
|||
|
|
@ -242,11 +242,13 @@ fn text_becomes_point_type() {
|
|||
// 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.
|
||||
// A gap of an em between words reads as a space, tracked on its own to keep the gap's width
|
||||
// (#508); 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_eq!(runs, [("Next", "Regular"), (" ", "Regular"), ("line", "Regular"), (" in bold", "Bold")]);
|
||||
assert!(t[1].runs[1].style.tracking > t[1].runs[0].style.tracking + 100.0, "{:?}", t[1].runs);
|
||||
assert_eq!(t[1].runs[3].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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,203 @@
|
|||
//! What a file's lines of type were (#508): the layer art ending a group's marked content belongs
|
||||
//! to, layers ordered as the pages mark them, a stroke written as stroked glyph outlines, spaces
|
||||
//! read from wide gaps, and lines wrapped in a frame rebuilt as area type.
|
||||
|
||||
use kurbo::{BezPath, PathEl, Point, Shape};
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{AppearanceItem, CharStyle, Document, Justify, Node, NodeKind, TextKind, TextObject};
|
||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with_catalog};
|
||||
|
||||
use crate::*;
|
||||
|
||||
const HELVETICA: &str = "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >>";
|
||||
|
||||
/// Groups "A" and "B" (A painted first) for a file of `pages` pages, and the catalog entries.
|
||||
fn groups(pages: usize) -> ([String; 2], String, String) {
|
||||
let (a, b) = (first_extra(pages), first_extra(pages) + 1);
|
||||
let objs = ["<< /Type /OCG /Name (A) >>".to_string(), "<< /Type /OCG /Name (B) >>".to_string()];
|
||||
let catalog = format!("/OCProperties << /OCGs [{a} 0 R {b} 0 R] /D << /Order [{b} 0 R {a} 0 R] >> >> ");
|
||||
let resources = format!("/Properties << /MC0 {a} 0 R /MC1 {b} 0 R >> {HELVETICA}");
|
||||
(objs, catalog, resources)
|
||||
}
|
||||
|
||||
/// A file of 100 × 100 pt pages drawing `contents`, with groups A (`/MC0`) and B (`/MC1`).
|
||||
fn layered(contents: &[&str]) -> Document {
|
||||
let (objs, catalog, resources) = groups(contents.len());
|
||||
let pages: Vec<PdfPage> = contents.iter().map(|c| PdfPage { resources: resources.clone(), ..PdfPage::new(100.0, 100.0, c) }).collect();
|
||||
import(&pdf_with_catalog(&pages, &[&objs[0], &objs[1]], &catalog, None)).unwrap()
|
||||
}
|
||||
|
||||
/// The layers' names, bottom first, and the kinds of their art.
|
||||
fn layers(d: &Document) -> Vec<(String, Vec<&'static str>)> {
|
||||
let kind = |n: &std::sync::Arc<Node>| match n.kind {
|
||||
NodeKind::Text(_) => "text",
|
||||
NodeKind::Group { .. } => "group",
|
||||
_ => "path",
|
||||
};
|
||||
d.layers.iter().map(|l| (l.name.clone().unwrap_or_default(), l.children().map_or(vec![], |c| c.iter().map(kind).collect()))).collect()
|
||||
}
|
||||
|
||||
fn texts(d: &Document) -> Vec<TextObject> {
|
||||
let mut v = vec![];
|
||||
d.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
v.push((**t).clone());
|
||||
}
|
||||
});
|
||||
v
|
||||
}
|
||||
|
||||
fn named(layers: &[(&str, &[&'static str])]) -> Vec<(String, Vec<&'static str>)> {
|
||||
layers.iter().map(|(n, k)| (n.to_string(), k.to_vec())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_ending_a_layers_marked_content_stays_in_its_layer() {
|
||||
// The line is still being gathered when the group's sequence ends.
|
||||
let d = layered(&["/OC /MC0 BDC 0 0 1 rg 0 0 10 10 re f BT /F1 12 Tf 10 50 Td (Lead) Tj ET EMC /OC /MC1 BDC EMC"]);
|
||||
assert_eq!(layers(&d), named(&[("A", &["path", "text"]), ("B", &[])]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clip_ending_in_a_later_group_stays_with_its_art() {
|
||||
// The clip opens in A, its art is drawn in A, and it closes after B's (empty) sequence.
|
||||
let d = layered(&["/OC /MC0 BDC q 0 0 50 50 re W n 1 0 0 rg 40 40 20 20 re f EMC /OC /MC1 BDC EMC Q"]);
|
||||
assert_eq!(layers(&d), named(&[("A", &["group"]), ("B", &[])]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_stack_as_the_pages_mark_them_when_one_first_paints_on_a_later_page() {
|
||||
// A, the bottom layer, is empty on page 1 and has art on page 2 only.
|
||||
let d = layered(&["/OC /MC0 BDC EMC /OC /MC1 BDC 1 0 0 rg 0 0 10 10 re f EMC", "/OC /MC0 BDC 0 0 1 rg 0 0 10 10 re f EMC /OC /MC1 BDC EMC"]);
|
||||
assert_eq!(layers(&d), named(&[("A", &["path"]), ("B", &["path"])]));
|
||||
// A group marked with no art on any page is an empty layer in its place.
|
||||
let d = layered(&["/OC /MC0 BDC EMC /OC /MC1 BDC 1 0 0 rg 0 0 10 10 re f EMC"]);
|
||||
assert_eq!(layers(&d), named(&[("A", &[]), ("B", &["path"])]));
|
||||
}
|
||||
|
||||
/// A 100 × 100 pt page drawing `content` in Helvetica.
|
||||
fn page(content: &str) -> Vec<u8> {
|
||||
vectorcraft_testkit::pdf::pdf(&[PdfPage { resources: HELVETICA.into(), ..PdfPage::new(100.0, 100.0, content) }], None)
|
||||
}
|
||||
|
||||
/// The outline of glyph `c` drawn at 24 pt from (x, 50), in PDF operators (y up), stroked.
|
||||
fn outline(c: char, x: f64) -> String {
|
||||
let opts = ImportOptions { text_as: TextAs::Outlines, ..Default::default() };
|
||||
let d = import_with_report(&page(&format!("BT /F1 24 Tf {x} 50 Td ({c}) Tj ET")), &opts).unwrap().document;
|
||||
let mut p: Option<BezPath> = None;
|
||||
d.walk(|n| {
|
||||
if let NodeKind::Path { path, .. } = &n.kind {
|
||||
p = Some(path.to_bezpath());
|
||||
}
|
||||
});
|
||||
let pt = |q: Point| format!("{:.3} {:.3}", q.x, 100.0 - q.y);
|
||||
let ops: String = p
|
||||
.unwrap()
|
||||
.elements()
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
PathEl::MoveTo(a) => format!("{} m ", pt(*a)),
|
||||
PathEl::LineTo(a) => format!("{} l ", pt(*a)),
|
||||
PathEl::QuadTo(a, b) => format!("{} {} {} c ", pt(*a), pt(*b), pt(*b)),
|
||||
PathEl::CurveTo(a, b, c) => format!("{} {} {} c ", pt(*a), pt(*b), pt(*c)),
|
||||
PathEl::ClosePath => "h ".into(),
|
||||
})
|
||||
.collect();
|
||||
ops + "S "
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stroke_written_as_stroked_glyph_outlines_is_the_types_stroke() {
|
||||
// "Hi": the H is 722 units wide in Helvetica.
|
||||
let strokes = format!("0 0 1 RG 0.5 w {}{}", outline('H', 10.0), outline('i', 10.0 + 0.722 * 24.0));
|
||||
let text = "BT 1 0 0 rg /F1 24 Tf 10 50 Td (Hi) Tj ET ";
|
||||
for (content, below) in [(format!("{text}{strokes}"), false), (format!("{strokes}{text}"), true)] {
|
||||
let d = import(&page(&content)).unwrap();
|
||||
let art = d.layers[0].children().unwrap();
|
||||
assert_eq!(art.len(), 1, "one object: {art:?}");
|
||||
let NodeKind::Text(t) = &art[0].kind else { panic!("{:?}", art[0].kind) };
|
||||
assert_eq!(t.plain_text(), "Hi");
|
||||
assert_eq!(t.first_style().fill.color(), Some(Color::rgb(1.0, 0.0, 0.0)));
|
||||
let ap = &art[0].appearance;
|
||||
let [AppearanceItem::Stroke(st)] = ap.items.as_slice() else { panic!("{ap:?}") };
|
||||
assert_eq!(st.paint.color(), Some(Color::rgb(0.0, 0.0, 1.0)));
|
||||
assert!((st.width - 0.5).abs() < 1e-6);
|
||||
assert_eq!(ap.contents_at(), usize::from(below), "a stroke drawn first paints below the characters");
|
||||
}
|
||||
// A stroke that isn't on the glyphs stays a path.
|
||||
let d = import(&page(&format!("{text}0 0 1 RG 0.5 w {}", outline('H', 40.0)))).unwrap();
|
||||
assert_eq!(d.layers[0].children().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// A 300 × 200 pt page with `lines` of (x, y from the top, size, text) in Source Sans 3.
|
||||
fn source_sans(lines: &[(f32, f32, f32, &str)]) -> Vec<u8> {
|
||||
crate::tests_live_text::text_pdf(lines, false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spaces_read_from_wide_gaps_keep_the_glyphs_after_them_in_place() {
|
||||
// A barcode's digits in three groups, set apart by more than a space.
|
||||
let (x1, x2) = (29.0, 69.0);
|
||||
let bytes = source_sans(&[(20.0, 50.0, 10.0, "3"), (x1, 50.0, 10.0, "533121"), (x2, 50.0, 10.0, "010629")]);
|
||||
let t = texts(&import(&bytes).unwrap());
|
||||
assert_eq!(t.len(), 1);
|
||||
assert_eq!(t[0].plain_text(), "3 533121 010629");
|
||||
let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t[0]);
|
||||
let at = |byte: usize| (t[0].xf * laid.glyphs.iter().find(|g| g.byte == byte).unwrap().origin).x;
|
||||
assert!((at(2) - f64::from(x1)).abs() < 0.05, "the second group starts where the file has it: {} vs {x1}", at(2));
|
||||
assert!((at(9) - f64::from(x2)).abs() < 0.05, "the third group starts where the file has it: {} vs {x2}", at(9));
|
||||
}
|
||||
|
||||
/// A document of area type `text` (Source Sans 3, 10 pt) `width` wide at (20, 30).
|
||||
fn area_doc(text: &str, justify: Justify, width: f64, space_before: f64) -> Document {
|
||||
let style = CharStyle { size: 10.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
|
||||
let mut t = TextObject::point(Point::ORIGIN, text, style);
|
||||
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, width, 200.0)) };
|
||||
t.xf = Affine::translate((20.0, 30.0));
|
||||
t.para.justify = justify;
|
||||
t.para.space_before = space_before;
|
||||
let mut d = Document::new(300.0, 300.0);
|
||||
let l = d.layers[0].id;
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// `d` exported (real text) and imported again.
|
||||
fn round_trip(d: &Document) -> Document {
|
||||
let settings: PdfSettings = serde_json::from_value(json!({"compression": {"compressText": false}, "advanced": {"outlineText": false}})).unwrap();
|
||||
let bytes = export_with_report(d, &PdfOptions { settings, ..Default::default() }).unwrap().bytes;
|
||||
import(&bytes).unwrap()
|
||||
}
|
||||
|
||||
const PROSE: &str = "Stop losing your sinkers on the cast: the clip holds the lead until a snag pulls it free, \
|
||||
so the fish swims on without the weight.\nIt works on every running rig and leaves the line clear.";
|
||||
|
||||
#[test]
|
||||
fn lines_wrapped_in_a_frame_come_back_as_area_type() {
|
||||
for (justify, space) in [(Justify::Left, 0.0), (Justify::JustifyLeft, 4.0)] {
|
||||
let d = round_trip(&area_doc(PROSE, justify, 120.0, space));
|
||||
let t = texts(&d);
|
||||
assert_eq!(t.len(), 1, "{justify:?}: one object, not a line each: {:?}", t.iter().map(TextObject::plain_text).collect::<Vec<_>>());
|
||||
assert_eq!(t[0].plain_text(), PROSE);
|
||||
assert_eq!(t[0].para.justify, justify);
|
||||
assert!((t[0].para.space_before - space).abs() < 0.01, "{}", t[0].para.space_before);
|
||||
let TextKind::Area { frame } = &t[0].kind else { panic!("{:?}", t[0].kind) };
|
||||
let w = frame.to_bezpath().bounding_box().width();
|
||||
assert!(w <= 120.5 && w > 100.0, "{w}");
|
||||
let origin = t[0].xf * Point::ZERO;
|
||||
assert!((origin.x - 20.0).abs() < 0.05, "{origin:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_that_were_not_wrapped_stay_point_type() {
|
||||
// A list: each line its own, none ending in a space.
|
||||
let bytes = source_sans(&[(20.0, 50.0, 10.0, "Hooks"), (20.0, 62.0, 10.0, "Swivels"), (20.0, 74.0, 10.0, "Leads")]);
|
||||
assert_eq!(texts(&import(&bytes).unwrap()).len(), 3);
|
||||
// Wrapped lines whose breaks a frame doesn't give again (a line far short of the others).
|
||||
let bytes = source_sans(&[(20.0, 50.0, 10.0, "One two three four five six "), (20.0, 62.0, 10.0, "a "), (20.0, 74.0, 10.0, "seven")]);
|
||||
assert_eq!(texts(&import(&bytes).unwrap()).len(), 3);
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ const SOURCE_SERIF: &[u8] = include_bytes!("../../../assets/fonts/SourceSerif4-R
|
|||
|
||||
/// A 300 × 200 pt page with `lines` of (x, y from the top, size, text) in Source Sans 3, and the
|
||||
/// same rotated 90° when `rotated`.
|
||||
fn text_pdf(lines: &[(f32, f32, f32, &str)], rotated: bool) -> Vec<u8> {
|
||||
pub(crate) fn text_pdf(lines: &[(f32, f32, f32, &str)], rotated: bool) -> Vec<u8> {
|
||||
let mut pdf = krilla::Document::new();
|
||||
let mut page = pdf.start_page_with(PageSettings::from_wh(300.0, 200.0).unwrap());
|
||||
let mut s = page.surface();
|
||||
|
|
@ -269,7 +269,7 @@ fn a_single_upright_glyph_comes_in_as_vertical_type_and_takes_no_horizontal_glyp
|
|||
let mut line = TextLine::new(at(100.0, 100.0), 1.0);
|
||||
assert!(line.push_upright(&look, at(100.0, 100.0), 1.0, Upright { top: Point::new(110.0, 82.0) }, "§"));
|
||||
assert!(!line.push(&look, at(130.0, 100.0), 1.0, 10.0, "a"), "a horizontal glyph starts another line");
|
||||
let (t, _) = line.finish().unwrap();
|
||||
let (t, ..) = line.finish().unwrap();
|
||||
assert!(t.vertical, "one upright glyph is vertical type");
|
||||
assert_eq!(t.plain_text(), "§");
|
||||
}
|
||||
|
|
@ -294,7 +294,7 @@ fn letter_spaced_vertical_type_keeps_its_characters_together() {
|
|||
let y = 100.0 + 30.0 * i as f64;
|
||||
assert!(line.push_upright(&look, at(y), 1.0, Upright { top: Point::new(110.0, y - 18.0) }, c));
|
||||
}
|
||||
let (t, _) = line.finish().unwrap();
|
||||
let (t, ..) = line.finish().unwrap();
|
||||
assert!(t.vertical);
|
||||
assert_eq!(t.plain_text(), "§§§", "letter spacing isn't a space");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -402,11 +402,14 @@ their stop opacity and stop where the shading doesn't extend. Text becomes point
|
|||
the file's font (by name; fonts that aren't available are listed in `warnings` and show in the fallback font, and
|
||||
every export that draws that type — PDF, EPS, EMF/WMF, raster images, SVG with outlined or embedded fonts — says in
|
||||
its `warnings` that it wrote the fallback font) — `textAs: "outlines"` keeps the glyph outlines the file draws instead
|
||||
(its embedded fonts, installed or not). Strokes stay live strokes (width, cap, join, miter limit, dash and paint), and
|
||||
(its embedded fonts, installed or not). Lines wrapped in a frame (each wrapped line ending in a space, sharing a left
|
||||
edge, font and leading) come back as one area type object, left aligned or justified, where laying it out breaks the
|
||||
lines where the file does; a stroke written as each glyph's outline stroked is the type object's stroke; glyphs set
|
||||
apart by gaps wider than a space keep their places. Strokes stay live strokes (width, cap, join, miter limit, dash and paint), and
|
||||
an object written as a fill and then a stroke of the same outline is one path with both. Optional content groups (the
|
||||
layers of PDF and PDF-compatible `.ai` files) become layers with their name, visibility (the default configuration's,
|
||||
or a view state that is off), print state and lock, nested as sublayers the way the file's layer order nests them; art
|
||||
that is off comes in as a hidden layer. Art outside them goes to a layer per page (except the opaque white page a `.ai`
|
||||
or a view state that is off), print state and lock, nested as sublayers the way the file's layer order nests them, stacked as the pages mark them
|
||||
(layers with no art come in empty); art that is off comes in as a hidden layer. Art outside them goes to a layer per page (except the opaque white page a `.ai`
|
||||
paints under its layers, which isn't art). `layers: false` gives one layer per page of only what shows:
|
||||
|
||||
```json
|
||||
|
|
|
|||
Loading…
Reference in New Issue