mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 20:08:42 +00:00
feat(pdf): preserve Type 3 font text runs as named groups (#6740)
This commit is contained in:
Generated
+1
@@ -6664,6 +6664,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"siphasher",
|
||||
"skrifa 0.47.0",
|
||||
"smallvec",
|
||||
"thiserror 2.0.21",
|
||||
|
||||
@@ -30,6 +30,7 @@ craft-pdf = { workspace = true }
|
||||
log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
smallvec = "1"
|
||||
siphasher = "1"
|
||||
weezl = "0.2"
|
||||
serde = { workspace = true }
|
||||
# The XMP packet that File Info is read from (file_info.rs); the version usvg already uses.
|
||||
|
||||
@@ -92,4 +92,44 @@ impl Builder<'_> {
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// A Type 3 glyph under an active ActualText span.
|
||||
pub(super) fn actual_type3_glyph(
|
||||
&mut self,
|
||||
key: u128,
|
||||
at: Placement,
|
||||
look: Look,
|
||||
opacity: f32,
|
||||
advance: f64,
|
||||
) -> bool {
|
||||
self.flush_glyphs();
|
||||
self.flush_type3();
|
||||
if let (Some(stroke), Some(line)) = (&look.stroke, &mut self.text)
|
||||
&& look.fill.is_none()
|
||||
&& line.stroke_actual(key, at, stroke.clone(), look.overprint_stroke)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let pending = self.actual.pending.take();
|
||||
let content = match pending.as_deref() {
|
||||
Some(text) if !text.is_empty() => TextContent::Actual(text),
|
||||
_ => TextContent::Pen,
|
||||
};
|
||||
let place = |line: &mut TextLine| {
|
||||
line.push_content(&look, at, opacity, advance, content)
|
||||
};
|
||||
if !self.text.as_mut().is_some_and(place) {
|
||||
self.flush_text();
|
||||
let mut line = TextLine::new(at, opacity);
|
||||
if !place(&mut line) {
|
||||
self.actual.pending = pending;
|
||||
return false;
|
||||
}
|
||||
self.text = Some(line);
|
||||
}
|
||||
if let Some(line) = &mut self.text {
|
||||
line.has_type3 = true;
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,18 @@ use super::{
|
||||
Upright, Walked, clipped, contains, fold_stop_opacity, function_paint, gradient_shape, is_rectangle, luminance, mask_spec, shading_gradient,
|
||||
};
|
||||
|
||||
fn is_colour_face(face: &vectorcraft_text::FontFace) -> bool {
|
||||
use skrifa::Tag;
|
||||
use skrifa::raw::TableProvider;
|
||||
let Ok(font) = skrifa::FontRef::new(face.file_data()) else {
|
||||
return false;
|
||||
};
|
||||
font.colr().is_ok()
|
||||
|| font.table_data(Tag::new(b"SVG ")).is_some()
|
||||
|| font.table_data(Tag::new(b"CBDT")).is_some()
|
||||
|| font.table_data(Tag::new(b"sbix")).is_some()
|
||||
}
|
||||
|
||||
impl<'p> Builder<'p> {
|
||||
pub(super) fn new(next: u64, colors: Colors<'p>, text_as: TextAs, route: bool) -> Self {
|
||||
Self {
|
||||
@@ -22,6 +34,7 @@ impl<'p> Builder<'p> {
|
||||
blend: BlendMode::Normal,
|
||||
page: Rect::ZERO,
|
||||
glyphs: None,
|
||||
type3_run: None,
|
||||
text: None,
|
||||
text_glyphs: vec![],
|
||||
text_as,
|
||||
@@ -102,6 +115,7 @@ impl<'p> Builder<'p> {
|
||||
self.root_groups.clear();
|
||||
self.blend = BlendMode::Normal;
|
||||
self.glyphs = None;
|
||||
self.type3_run = None;
|
||||
self.text = None;
|
||||
self.text_glyphs.clear();
|
||||
self.mask = None;
|
||||
@@ -221,13 +235,48 @@ impl<'p> Builder<'p> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Finish the open glyph runs and the art drawn through the current soft mask.
|
||||
/// Finish the open glyph runs, type 3 runs and the art drawn through the current soft mask.
|
||||
pub(super) fn flush(&mut self) {
|
||||
self.flush_glyphs();
|
||||
self.flush_type3();
|
||||
self.flush_text();
|
||||
self.flush_masked();
|
||||
}
|
||||
|
||||
/// Finish the open Type 3 font text run as a named group `<g>`.
|
||||
pub(super) fn flush_type3(&mut self) {
|
||||
if let Some(run) = self.type3_run.take()
|
||||
&& !run.nodes.is_empty()
|
||||
{
|
||||
let Some(left) = self.objects_left.checked_sub(1) else {
|
||||
self.warn(super::entry::TOO_MANY_OBJECTS);
|
||||
return;
|
||||
};
|
||||
self.objects_left = left;
|
||||
let mut g = Node::group(self.id(), run.nodes);
|
||||
g.name = Some("<g>".to_string());
|
||||
self.emit(Arc::new(g), run.group);
|
||||
}
|
||||
}
|
||||
|
||||
/// Add nodes from an interpreted Type 3 glyph to the current Type 3 run.
|
||||
pub(super) fn push_type3_nodes(&mut self, nodes: Vec<Arc<Node>>) {
|
||||
if nodes.is_empty() {
|
||||
return;
|
||||
}
|
||||
let group = self.current_group();
|
||||
if let Some(run) = &mut self.type3_run {
|
||||
if run.group != group {
|
||||
self.flush_type3();
|
||||
self.type3_run = Some(super::state::Type3Run { nodes, group });
|
||||
} else {
|
||||
run.nodes.extend(nodes);
|
||||
}
|
||||
} else {
|
||||
self.type3_run = Some(super::state::Type3Run { nodes, group });
|
||||
}
|
||||
}
|
||||
|
||||
/// The art drawn through the graphics state's soft mask so far, as one masked object.
|
||||
fn flush_masked(&mut self) {
|
||||
if self.masked.is_empty() {
|
||||
@@ -391,7 +440,7 @@ impl<'p> Builder<'p> {
|
||||
let Some(mut line) = self.text.take() else { return false };
|
||||
let held = std::mem::take(&mut line.held);
|
||||
// Type the file names itself (ActualText) is in reading order as it stands.
|
||||
let checked = !glyphs.is_empty() && line.mapped();
|
||||
let checked = !glyphs.is_empty() && line.mapped() && !line.has_type3;
|
||||
let mut outlined = false;
|
||||
if let Some((t, opacity, facts)) = line.finish() {
|
||||
if checked && reshaped::reshapes(&t.plain_text()) && !reshaped::draws_as(&t, &facts.ink) {
|
||||
@@ -509,10 +558,11 @@ impl<'p> Builder<'p> {
|
||||
|
||||
/// Read a soft mask, pattern cell or type 3 glyph (`read`) on its own: its art, in the
|
||||
/// coordinates `page` has. `None` when nested too deeply.
|
||||
fn nested(&mut self, page: Rect, read: impl FnOnce(&mut Self)) -> Option<Vec<Arc<Node>>> {
|
||||
pub(super) fn nested(&mut self, page: Rect, read: impl FnOnce(&mut Self)) -> Option<Vec<Arc<Node>>> {
|
||||
if self.nested >= MAX_NESTED {
|
||||
return None;
|
||||
}
|
||||
let outer_type3 = self.type3_run.take();
|
||||
self.flush();
|
||||
let saved = Saved {
|
||||
stack: std::mem::replace(&mut self.stack, vec![Frame::new(FrameKind::Root)]),
|
||||
@@ -520,12 +570,14 @@ impl<'p> Builder<'p> {
|
||||
blend: std::mem::replace(&mut self.blend, BlendMode::Normal),
|
||||
mask: self.mask.take(),
|
||||
page: std::mem::replace(&mut self.page, page),
|
||||
type3_run: outer_type3,
|
||||
};
|
||||
self.nested += 1;
|
||||
read(self);
|
||||
let art = self.close_frames();
|
||||
self.nested -= 1;
|
||||
(self.stack, self.root_groups, self.blend, self.mask, self.page) = (saved.stack, saved.root_groups, saved.blend, saved.mask, saved.page);
|
||||
(self.stack, self.root_groups, self.blend, self.mask, self.page, self.type3_run) =
|
||||
(saved.stack, saved.root_groups, saved.blend, saved.mask, saved.page, saved.type3_run);
|
||||
Some(art)
|
||||
}
|
||||
|
||||
@@ -799,6 +851,125 @@ impl<'p> Builder<'p> {
|
||||
self.text_glyphs.push(GlyphRun { path, paint, opacity, stroke, scale, overprint });
|
||||
}
|
||||
|
||||
fn type3_font_name(&mut self, key: u128) -> FontInfo {
|
||||
if let Some(n) = self.font_names.get(&key) {
|
||||
return n.clone();
|
||||
}
|
||||
let name = self.fonts.get(&key).map(|f| f.name.clone());
|
||||
let ps = name.as_deref().map(|n| match n.split_once('+') {
|
||||
Some((tag, rest)) if tag.len() == 6 && tag.chars().all(|c| c.is_ascii_uppercase()) => rest.to_string(),
|
||||
_ => n.to_string(),
|
||||
});
|
||||
let installed = ps.as_deref().and_then(|ps| vectorcraft_text::FontDb::global().find_postscript(ps));
|
||||
let n = match (installed, name) {
|
||||
(Some(face), _) => FontInfo { family: face.family.clone(), style: face.style.clone(), face: Some(face) },
|
||||
(None, Some(name)) => {
|
||||
let families = self.families.get_or_insert_with(Families::available);
|
||||
let (family, style, found) = families.resolve(&name, None, false);
|
||||
if !found && !family.is_empty() && !self.missing_fonts.contains(&family) {
|
||||
self.missing_fonts.push(family.clone());
|
||||
}
|
||||
let face = vectorcraft_text::FontDb::global().face(&family, &style);
|
||||
FontInfo { family, style, face }
|
||||
}
|
||||
(None, None) => {
|
||||
let d = vectorcraft_doc::CharStyle::default();
|
||||
FontInfo { family: d.font_family, style: d.font_style, face: None }
|
||||
}
|
||||
};
|
||||
self.font_names.insert(key, n.clone());
|
||||
n
|
||||
}
|
||||
|
||||
/// Type 3 glyph as live type: added to the line being gathered if it has a ToUnicode mapping.
|
||||
pub(super) fn type_type3_glyph(
|
||||
&mut self,
|
||||
t3: &craft_pdf::interpret::font::Type3Glyph<'_>,
|
||||
m: Affine,
|
||||
scale: f64,
|
||||
paint: Option<&craft_pdf::interpret::Paint<'_>>,
|
||||
stroke: Option<&StrokeProps>,
|
||||
overprint: bool,
|
||||
) -> bool {
|
||||
if self.text_as != TextAs::Text || self.nested > 0 {
|
||||
return false;
|
||||
}
|
||||
let t3_key = craft_pdf::interpret::CacheKey::cache_key(t3);
|
||||
let Some(&(font_key, has_to_unicode)) = self.scan.type3_cache_map.get(&t3_key) else {
|
||||
return false;
|
||||
};
|
||||
let (is_type3, to_unicode, is_colour) = match self.fonts.get(&font_key) {
|
||||
Some(f) => (f.is_type3, has_to_unicode || f.to_unicode, f.is_colour),
|
||||
None => (true, has_to_unicode, false),
|
||||
};
|
||||
let authored = self.actual.active();
|
||||
if !is_type3 || (!to_unicode && !authored) || is_colour {
|
||||
return false;
|
||||
}
|
||||
let unicode = t3.as_unicode().filter(|u| !super::cid::replacement(u));
|
||||
if !authored && unicode.is_none() {
|
||||
return false;
|
||||
}
|
||||
let text: String = match unicode {
|
||||
Some(BfString::Char(c)) => c.to_string(),
|
||||
Some(BfString::String(s)) => s,
|
||||
None => String::new(),
|
||||
};
|
||||
let Some(at) = Placement::of(m) else { return false };
|
||||
if !authored && (text.is_empty() || text.chars().any(|c| c.is_control())) {
|
||||
return false;
|
||||
}
|
||||
let width = t3.advance_width().filter(|w| w.is_finite());
|
||||
let advance = width.map_or(at.size * 0.5, |w| w as f64 / 1000.0 * at.size * at.h_scale / 100.0);
|
||||
let info = self.type3_font_name(font_key);
|
||||
if info.face.as_ref().is_some_and(|f| is_colour_face(f)) {
|
||||
return false;
|
||||
}
|
||||
let (paint, opacity) = match paint {
|
||||
Some(p) => {
|
||||
let (paint, opacity) = self.paint(p, stroke.is_some(), self.walked(true));
|
||||
(Some(paint), opacity)
|
||||
}
|
||||
None => (None, 1.0),
|
||||
};
|
||||
let stroke = stroke.zip(paint.clone()).map(|(p, paint)| (paint, line_width(p, scale)));
|
||||
let look = Look {
|
||||
font: font_key,
|
||||
family: info.family,
|
||||
style: info.style,
|
||||
version: None,
|
||||
size: at.size,
|
||||
h_scale: at.h_scale,
|
||||
overprint_fill: stroke.is_none() && overprint,
|
||||
overprint_stroke: stroke.is_some() && overprint,
|
||||
fill: if stroke.is_none() { paint } else { None },
|
||||
stroke,
|
||||
};
|
||||
if authored {
|
||||
return self.actual_type3_glyph(font_key, at, look, opacity, advance);
|
||||
}
|
||||
let text = visual_glyph_text(text);
|
||||
let place = |line: &mut TextLine| line.push(&look, at, opacity, advance, &text);
|
||||
self.flush_glyphs();
|
||||
self.flush_type3();
|
||||
if let (Some(s), Some(line)) = (&look.stroke, &mut self.text)
|
||||
&& look.fill.is_none()
|
||||
&& line.stroke_last(font_key, at, s.clone(), overprint)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if !self.text.as_mut().is_some_and(place) {
|
||||
self.flush_text();
|
||||
let mut line = TextLine::new(at, opacity);
|
||||
place(&mut line);
|
||||
self.text = Some(line);
|
||||
}
|
||||
if let Some(line) = &mut self.text {
|
||||
line.has_type3 = true;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Glyph `o` as type: added to the line being gathered (or starting one). `false`: it keeps
|
||||
/// its outline (no Unicode text, mirrored, or not type mode). No `paint`: invisible text,
|
||||
/// type with neither fill nor stroke.
|
||||
|
||||
@@ -33,6 +33,7 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
|
||||
fn draw_path(&mut self, path: &BezPath, transform: Affine, paint: &craft_pdf::interpret::Paint<'a>, draw_mode: &PathDrawMode) {
|
||||
self.flush_glyphs();
|
||||
self.flush_type3();
|
||||
let walked = self.walked(false);
|
||||
if self.hold_glyph_stroke(path, transform, paint, draw_mode, walked) {
|
||||
return;
|
||||
@@ -174,7 +175,7 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
GlyphDrawMode::Invisible => {
|
||||
let typed = match glyph {
|
||||
Glyph::Outline(o) => self.type_glyph(o, transform * glyph_transform, mean_scale(transform), None, None, false),
|
||||
Glyph::Type3(_) => false,
|
||||
Glyph::Type3(t3) => self.type_type3_glyph(t3, transform * glyph_transform, mean_scale(transform), None, None, false),
|
||||
};
|
||||
if !typed {
|
||||
self.warn(INVISIBLE_TEXT_NOTE);
|
||||
@@ -186,6 +187,7 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
};
|
||||
match glyph {
|
||||
Glyph::Outline(o) => {
|
||||
self.flush_type3();
|
||||
let scale = mean_scale(transform);
|
||||
let overprint = self.glyph_overprint(stroke.is_some());
|
||||
if self.type_glyph(o, transform * glyph_transform, scale, Some(paint), stroke.as_ref(), overprint) {
|
||||
@@ -216,16 +218,26 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
if self.nested >= MAX_NESTED {
|
||||
return;
|
||||
}
|
||||
self.flush();
|
||||
self.nested += 1;
|
||||
t3.interpret(self, transform, glyph_transform, paint);
|
||||
self.nested -= 1;
|
||||
let scale = mean_scale(transform);
|
||||
let overprint = self.glyph_overprint(stroke.is_some());
|
||||
if self.type_type3_glyph(t3, transform * glyph_transform, scale, Some(paint), stroke.as_ref(), overprint) {
|
||||
return;
|
||||
}
|
||||
self.flush_glyphs();
|
||||
self.flush_text();
|
||||
let art = self.nested(self.page, |b| {
|
||||
t3.interpret(b, transform, glyph_transform, paint);
|
||||
});
|
||||
if let Some(nodes) = art {
|
||||
self.push_type3_nodes(nodes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) {
|
||||
self.flush_glyphs();
|
||||
self.flush_type3();
|
||||
self.flush_text();
|
||||
match image {
|
||||
Image::Raster(r) => {
|
||||
|
||||
@@ -62,6 +62,13 @@ pub(super) struct GlyphRun {
|
||||
/// swatch made for it.
|
||||
pub(super) type PatternReadings = (u32, Vec<(Vec<Paint>, String)>);
|
||||
|
||||
/// A run of Type 3 glyph graphics preserved inside a named group.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct Type3Run {
|
||||
pub(super) nodes: Vec<Arc<Node>>,
|
||||
pub(super) group: Option<usize>,
|
||||
}
|
||||
|
||||
/// Builder state a nested interpretation (soft mask, pattern cell) starts afresh and gives back.
|
||||
pub(super) struct Saved {
|
||||
pub(super) stack: Vec<Frame>,
|
||||
@@ -69,6 +76,7 @@ pub(super) struct Saved {
|
||||
pub(super) blend: BlendMode,
|
||||
pub(super) mask: Option<Arc<MaskSpec>>,
|
||||
pub(super) page: Rect,
|
||||
pub(super) type3_run: Option<Type3Run>,
|
||||
}
|
||||
|
||||
pub(super) struct Builder<'p> {
|
||||
@@ -80,6 +88,7 @@ pub(super) struct Builder<'p> {
|
||||
pub(super) blend: BlendMode,
|
||||
pub(super) page: Rect,
|
||||
pub(super) glyphs: Option<GlyphRun>,
|
||||
pub(super) type3_run: Option<Type3Run>,
|
||||
pub(super) text: Option<TextLine>,
|
||||
/// The glyphs of the open line of type (`text`) that show, each as the outline it would be
|
||||
/// kept as: what the line becomes when its type wouldn't draw as the file does
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use siphasher::sip128::{Hasher128, SipHasher13};
|
||||
|
||||
use crate::import_color::items;
|
||||
use craft_pdf::interpret::CacheKey;
|
||||
use craft_pdf::syntax::Pdf;
|
||||
@@ -250,6 +252,13 @@ fn pdf_doc_char(c: u8) -> char {
|
||||
mapped.copied().unwrap_or(char::from(c))
|
||||
}
|
||||
|
||||
/// Calculate a 128-bit siphash of a value, matching hayro-interpret's `hash128`.
|
||||
pub(crate) fn hash128<T: std::hash::Hash + ?Sized>(value: &T) -> u128 {
|
||||
let mut state = SipHasher13::new();
|
||||
value.hash(&mut state);
|
||||
state.finish128().as_u128()
|
||||
}
|
||||
|
||||
/// One page as the interpreter walks it.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Scan {
|
||||
@@ -282,6 +291,8 @@ pub(crate) struct Scan {
|
||||
/// fills and of its strokes (indices into [`Self::inks`]), where all of its text shown in an
|
||||
/// ink is shown in the same one.
|
||||
text_inks: Changes<[Option<u32>; 2]>,
|
||||
/// Type 3 glyph cache key -> (font cache key, whether font has /ToUnicode).
|
||||
pub type3_cache_map: HashMap<u128, (u128, bool)>,
|
||||
}
|
||||
|
||||
impl Scan {
|
||||
@@ -642,9 +653,65 @@ impl Walker<'_, '_> {
|
||||
if let Some(f) = res.fonts.get::<Dict<'_>>(key.as_ref())
|
||||
&& let Some(name) = font_name(&f)
|
||||
{
|
||||
self.fonts.entry(f.cache_key()).or_insert_with(|| {
|
||||
let is_type3 = f.get::<Name<'_>>(b"Subtype").is_some_and(|s| s.as_ref() == b"Type3");
|
||||
let to_unicode = f.get::<Object<'_>>(b"ToUnicode").is_some();
|
||||
let f_key = f.cache_key();
|
||||
const MAX_TYPE3_CACHE_MAP: usize = 1 << 20;
|
||||
if is_type3 && self.out.type3_cache_map.len().saturating_add(257) <= MAX_TYPE3_CACHE_MAP {
|
||||
for gid in 0..=256u32 {
|
||||
let hash = hash128(&(f_key, skrifa::GlyphId::new(gid)));
|
||||
self.out.type3_cache_map.insert(hash, (f_key, to_unicode));
|
||||
}
|
||||
}
|
||||
let is_colour = if is_type3 {
|
||||
let mut colour = false;
|
||||
let mut seen_streams = HashSet::new();
|
||||
let mut decoded_bytes = 0usize;
|
||||
const MAX_CHARPROC_BYTES: usize = 16 << 20;
|
||||
if let Some(char_procs) = f.get::<Dict<'_>>(b"CharProcs") {
|
||||
for key in char_procs.keys() {
|
||||
if let Some(stream) = char_procs.get::<Stream<'_>>(key.as_ref()) {
|
||||
if !seen_streams.insert(stream.cache_key()) {
|
||||
continue;
|
||||
}
|
||||
if decoded_bytes >= MAX_CHARPROC_BYTES {
|
||||
colour = true;
|
||||
break;
|
||||
}
|
||||
if let Ok(data) = stream.decoded() {
|
||||
decoded_bytes = decoded_bytes.saturating_add(data.len());
|
||||
let mut iter = TypedIter::new(data.as_ref());
|
||||
while let Some(op) = iter.next() {
|
||||
match op {
|
||||
TypedInstruction::ColorGlyph(_) => {
|
||||
colour = true;
|
||||
break;
|
||||
}
|
||||
TypedInstruction::ShapeGlyph(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if colour {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
colour
|
||||
} else {
|
||||
false
|
||||
};
|
||||
self.fonts.entry(f_key).or_insert_with(|| {
|
||||
let embedded = embeds_glyphs(&f);
|
||||
FontNote { name, embedded, vertical: vertical_metrics(&f, embedded) }
|
||||
FontNote {
|
||||
name,
|
||||
embedded,
|
||||
vertical: vertical_metrics(&f, embedded),
|
||||
is_type3,
|
||||
to_unicode,
|
||||
is_colour,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -879,6 +946,12 @@ pub(crate) struct FontNote {
|
||||
pub embedded: bool,
|
||||
/// A composite font written vertically: its descendant's metrics.
|
||||
pub vertical: Option<Vertical>,
|
||||
/// Whether this is a Type 3 font.
|
||||
pub is_type3: bool,
|
||||
/// Whether this font specifies a /ToUnicode map.
|
||||
pub to_unicode: bool,
|
||||
/// Whether this Type 3 font contains coloured glyph descriptions (d0/ColorGlyph).
|
||||
pub is_colour: bool,
|
||||
}
|
||||
|
||||
/// The most CIDs a vertical font's widths are read for (the most a CID-keyed font has).
|
||||
@@ -1020,6 +1093,15 @@ fn cid_ranges(a: &Array<'_>, per: usize, mut each: impl FnMut(u32, &[f64])) {
|
||||
/// descendant CIDFont's `BaseFont` is taken, the font program's own name (§9.7.4.1); without one,
|
||||
/// the `BaseFont` without the CMap's name on its end.
|
||||
fn font_name(f: &Dict<'_>) -> Option<String> {
|
||||
if f.get::<Name<'_>>(b"Subtype").is_some_and(|s| s.as_ref() == b"Type3") {
|
||||
if let Some(base) = f.get::<Name<'_>>(b"BaseFont") {
|
||||
return Some(String::from_utf8_lossy(base.as_ref()).into_owned());
|
||||
}
|
||||
if let Some(name) = f.get::<Name<'_>>(b"Name") {
|
||||
return Some(String::from_utf8_lossy(name.as_ref()).into_owned());
|
||||
}
|
||||
return Some("Type3".to_string());
|
||||
}
|
||||
let base = String::from_utf8_lossy(f.get::<Name<'_>>(b"BaseFont")?.as_ref()).into_owned();
|
||||
if f.get::<Name<'_>>(b"Subtype").is_none_or(|s| s.as_ref() != b"Type0") {
|
||||
return Some(base);
|
||||
|
||||
@@ -218,6 +218,8 @@ pub(crate) struct TextLine {
|
||||
/// The strokes of its glyphs' outlines drawn between them, one per glyph with ink so far
|
||||
/// (written right after the line).
|
||||
pub held: Vec<Node>,
|
||||
/// Whether this line holds Type 3 font glyphs.
|
||||
pub has_type3: bool,
|
||||
}
|
||||
|
||||
/// Where the file put a glyph: the glyph's index in the line, and its advance in the file's font
|
||||
@@ -266,6 +268,7 @@ impl TextLine {
|
||||
drawn_spaces: vec![],
|
||||
drawn_space: None,
|
||||
held: vec![],
|
||||
has_type3: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,25 @@ fn type3(first: usize, upm: f64) -> Vec<String> {
|
||||
vec![font, a, b]
|
||||
}
|
||||
|
||||
/// A Type 3 font with a ToUnicode CMap.
|
||||
fn type3_with_to_unicode(first: usize, upm: f64) -> Vec<String> {
|
||||
let s = 1.0 / upm;
|
||||
let (h, w) = (upm * 0.5, upm);
|
||||
let a = stream("", &format!("{h} 0 0 0 {h} {upm} d1 0 0 {} {} re f", h * 0.8, upm * 0.7));
|
||||
let b = stream("", &format!("{w} 0 0 0 {w} {upm} d1 0 0 {} {} re f", w * 0.9, upm * 0.7));
|
||||
let to_u = stream(
|
||||
"",
|
||||
"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def /CMapName /T3-UCS def /CMapType 2 def 1 begincodespacerange <00> <FF> endcodespacerange 2 beginbfchar\n<61> <0061>\n<62> <0062>\nendbfchar endcmap CMapName currentdict /CMap defineresource pop end end",
|
||||
);
|
||||
let font = format!(
|
||||
"<< /Type /Font /Subtype /Type3 /FontBBox [0 0 {w} {w}] /FontMatrix [{s} 0 0 {s} 0 0] /CharProcs << /a {} 0 R /b {} 0 R >> /Encoding << /Type /Encoding /Differences [97 /a /b] >> /FirstChar 97 /LastChar 98 /Widths [{h} {w}] /ToUnicode {} 0 R /Resources << >> >>",
|
||||
first + 1,
|
||||
first + 2,
|
||||
first + 3
|
||||
);
|
||||
vec![font, a, b, to_u]
|
||||
}
|
||||
|
||||
/// A 300 × 200 pt page drawing `content`, plus `entries` in the page dictionary, with fonts:
|
||||
/// - `/F1`: Source Sans 3, embedded, a simple TrueType font;
|
||||
/// - `/F4`: Source Sans 3 as a Type 0 font, Identity-H (two-byte glyph ids, see [`gids`]);
|
||||
@@ -146,9 +165,13 @@ fn sized_page(w: f64, h: f64, content: &str, entries: &str) -> Vec<u8> {
|
||||
let t3 = n + objs.len();
|
||||
objs.extend(type3(t3, 1000.0));
|
||||
objs.extend(type3(t3 + 3, 100.0));
|
||||
let t3u = t3 + 6;
|
||||
objs.extend(type3_with_to_unicode(t3u, 1000.0));
|
||||
let t30u = t3u + 4;
|
||||
objs.extend(type3_with_to_unicode(t30u, 100.0));
|
||||
let refs: Vec<&str> = objs.iter().map(String::as_str).collect();
|
||||
let resources = format!(
|
||||
"/Font << /F1 {} 0 R /F4 {} 0 R /F5 {} 0 R /F6 {} 0 R /T3 {t3} 0 R /T30 {} 0 R /HV << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> >>",
|
||||
"/Font << /F1 {} 0 R /F4 {} 0 R /F5 {} 0 R /F6 {} 0 R /T3 {t3} 0 R /T30 {} 0 R /T3U {t3u} 0 R /T30U {t30u} 0 R /HV << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> >>",
|
||||
n + 2,
|
||||
n + 5,
|
||||
n + 6,
|
||||
@@ -416,7 +439,16 @@ fn vertical_glyphs_narrower_than_an_em_stay_on_their_column() {
|
||||
fn type3_glyphs_follow_the_font_matrix() {
|
||||
for font in ["T3", "T30"] {
|
||||
let d = open_as(&page(&format!("BT /{font} 20 Tf 20 50 Td (ab) Tj ET"), ""), TextAs::Text);
|
||||
assert!(texts(&d).is_empty(), "Type 3 text keeps its outlines");
|
||||
assert!(texts(&d).is_empty(), "Type 3 text without ToUnicode keeps its outlines");
|
||||
let mut groups = 0;
|
||||
d.walk(|n| {
|
||||
if let NodeKind::Group { .. } = &n.kind
|
||||
&& n.name.as_deref() == Some("<g>")
|
||||
{
|
||||
groups += 1;
|
||||
}
|
||||
});
|
||||
assert_eq!(groups, 1, "{font}: Type 3 text run must be preserved inside named group <g>");
|
||||
let mut boxes = contours(&d);
|
||||
boxes.sort_by(|a, b| a.x0.total_cmp(&b.x0));
|
||||
// `a` at x 20 (0.4 em = 8 pt wide), `b` after its half-em advance; 0.7 em = 14 pt tall,
|
||||
@@ -429,6 +461,60 @@ fn type3_glyphs_follow_the_font_matrix() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Type 3 fonts with ToUnicode import as live text in Text mode, and as named groups <g> in Paths mode.
|
||||
/// Tests both 1000 UPM (/T3U) and 100 UPM (/T30U) to verify FontMatrix scaling.
|
||||
#[test]
|
||||
fn type3_font_with_to_unicode_imports_as_live_text() {
|
||||
for font in ["T3U", "T30U"] {
|
||||
let d_text = open_as(&page(&format!("BT /{font} 20 Tf 20 50 Td (ab) Tj ET"), ""), TextAs::Text);
|
||||
let t = texts(&d_text);
|
||||
assert_eq!(t.len(), 1, "{font}: Type 3 with ToUnicode should import as live text");
|
||||
assert_eq!(t[0].plain_text(), "ab");
|
||||
let run = &t[0].runs[0];
|
||||
assert!((run.style.size - 20.0).abs() < 1e-3, "{font}: font size should be 20 pt, got {}", run.style.size);
|
||||
|
||||
let d_paths = open_as(&page(&format!("BT /{font} 20 Tf 20 50 Td (ab) Tj ET"), ""), TextAs::Outlines);
|
||||
assert!(texts(&d_paths).is_empty(), "In Outlines mode, Type 3 text should be outlines");
|
||||
let mut groups = 0;
|
||||
d_paths.walk(|n| {
|
||||
if let NodeKind::Group { .. } = &n.kind
|
||||
&& n.name.as_deref() == Some("<g>")
|
||||
{
|
||||
groups += 1;
|
||||
}
|
||||
});
|
||||
assert_eq!(groups, 1, "In Paths mode, Type 3 text should be grouped in <g>");
|
||||
}
|
||||
}
|
||||
|
||||
/// Invisible Type 3 text (mode 3) with ToUnicode imports as searchable text.
|
||||
#[test]
|
||||
fn type3_invisible_with_to_unicode_imports_as_searchable_text() {
|
||||
let d = open_as(&page("BT /T3U 20 Tf 3 Tr 20 50 Td (ab) Tj ET", ""), TextAs::Text);
|
||||
let t = texts(&d);
|
||||
assert_eq!(t.len(), 1, "Invisible Type 3 text with ToUnicode imports as searchable text");
|
||||
assert_eq!(t[0].plain_text(), "ab");
|
||||
for r in &t[0].runs {
|
||||
assert!(r.style.fill.is_none(), "Invisible text has no fill: {:?}", r.style.fill);
|
||||
assert!(!r.style.has_stroke(), "Invisible text has no stroke");
|
||||
}
|
||||
}
|
||||
|
||||
/// Type 3 text with ActualText replacement honors authored text or suppression.
|
||||
#[test]
|
||||
fn type3_with_actual_text() {
|
||||
// Authored replacement "XYZ" over Type 3 glyphs (ab)
|
||||
let d = open_as(&page("/Span << /ActualText (XYZ) >> BDC BT /T3U 20 Tf 20 50 Td (ab) Tj ET EMC", ""), TextAs::Text);
|
||||
let t = texts(&d);
|
||||
assert_eq!(t.len(), 1, "Type 3 with ActualText should import as live text");
|
||||
assert_eq!(t[0].plain_text(), "XYZ");
|
||||
|
||||
// Empty ActualText suppresses mapped text
|
||||
let d_empty = open_as(&page("/Span << /ActualText () >> BDC BT /T3U 20 Tf 20 50 Td (ab) Tj ET EMC", ""), TextAs::Text);
|
||||
let t_empty = texts(&d_empty);
|
||||
assert!(t_empty.is_empty(), "Empty ActualText should suppress text");
|
||||
}
|
||||
|
||||
/// A font that isn't embedded or installed is set in a stand-in: the line still starts where
|
||||
/// the file starts it, on its baseline, and is as long as the file's.
|
||||
#[test]
|
||||
|
||||
+4
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user