PDF import (#337 follow-up): keep missing-font text live, decide each font's face once, Kangxi fix for CID text only
- Text in fonts that aren't installed comes in as live type in the fallback font again, named as before (Helvetica-Bold → Helvetica Bold), not outlines plus pink copies on a non-printing layer. - The installed face that matches a PDF font is decided once per font from its first comparable glyph, not per glyph (loading up to 64 faces each time, and switching style mid-line). - Kangxi radicals are unified only for text from Adobe's CID tables, not ToUnicode maps. - A line is laid out again only when tracking changed it. - CidText::of is tested on data that isn't a CID-keyed CFF (embedded fonts are untrusted). - NOTICE: Adobe CMap resources (BSD-3-Clause) bundled through hayro-cmap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
df31117196
commit
5be1790717
32
NOTICE
32
NOTICE
|
|
@ -34,3 +34,35 @@ Builds made with craft-fonts (the CRAFT_FONTS_DIR build option; all official rel
|
|||
fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ
|
||||
UDPGothic, Shippori Mincho and BIZ UDMincho; web builds BIZ UDPGothic Regular only) under the SIL
|
||||
Open Font License 1.1. Their license texts ship with the release packages as OFL-<family>.txt.
|
||||
|
||||
PDF import decodes CJK text with Adobe's CMap resources (https://github.com/adobe-type-tools/cmap-resources),
|
||||
compiled into the app through the hayro-cmap crate under the BSD 3-Clause License:
|
||||
|
||||
Copyright 1990-2023 Adobe. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
|
||||
Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
|
||||
Neither the name of Adobe nor the names of its contributors may be
|
||||
used to endorse or promote products derived from this software without
|
||||
specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
|||
- **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`.
|
||||
- **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names.
|
||||
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.
|
||||
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed keeps its outlines with a pink editable copy on a non-printing layer.
|
||||
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed.
|
||||
- **Files (M4):**
|
||||
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash, background save and export.
|
||||
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units; PDF and PDF-compatible `.ai` in and out (layers, masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3).
|
||||
|
|
|
|||
|
|
@ -112,7 +112,6 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
|||
b.begin_page(scan);
|
||||
interpret_page(page, &mut ctx, &mut b);
|
||||
let mut parts = b.end_page();
|
||||
let copies = std::mem::take(&mut b.copies);
|
||||
// A file with several artboards writes, on each page, the art of its neighbours that
|
||||
// reaches into the page's box: art lying wholly outside this page is theirs (each page
|
||||
// draws it shifted by its own artboard spacing), so keep it only where it belongs.
|
||||
|
|
@ -161,16 +160,6 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
|||
}
|
||||
}
|
||||
}
|
||||
if !copies.is_empty() {
|
||||
// Over the page: editable copies of the text set in fonts that aren't installed. The
|
||||
// outlines below are what prints and exports.
|
||||
let mut copy = Node::layer(b.id(), &format!("{COPIES_LAYER} {}", number + 1), LayerColor::Preset(((i + 1) % 27) as u8));
|
||||
if let NodeKind::Layer { children: c, printable, .. } = &mut copy.kind {
|
||||
*c = copies;
|
||||
*printable = false;
|
||||
}
|
||||
slots.push(Slot::Page(Box::new(copy)));
|
||||
}
|
||||
}
|
||||
if placeholder {
|
||||
return Err(PdfError::PlaceholderOnly);
|
||||
|
|
@ -215,9 +204,7 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
|||
}
|
||||
if !b.missing_fonts.is_empty() {
|
||||
let list = b.missing_fonts.join(", ");
|
||||
b.warn(&format!(
|
||||
"fonts not installed ({list}): their text is kept as outlines, with editable copies in substitute fonts (pink) on the non-printing layer “{COPIES_LAYER}”"
|
||||
));
|
||||
b.warn(&format!("fonts that aren't available show in the fallback font until they are: {list}"));
|
||||
}
|
||||
doc.fix_next_id();
|
||||
doc.reserve_ids(b.next);
|
||||
|
|
@ -233,20 +220,17 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
|||
Ok(ImportReport { document: doc, warnings, native: crate::editing::editing_in(&pdf) })
|
||||
}
|
||||
|
||||
/// The non-printing layer of editable copies of text in fonts that aren't installed (+ page number).
|
||||
pub const COPIES_LAYER: &str = "Editable Text (substitute fonts, not printed)";
|
||||
|
||||
/// The characters of a CID-keyed CFF font embedded without a ToUnicode map (as macOS writes
|
||||
/// Hiragino): each glyph's CID from the font's charset, then Adobe's CID → Unicode table of its
|
||||
/// character collection (Japan1, GB1, CNS1, Korea1).
|
||||
struct CidText {
|
||||
pub(crate) struct CidText {
|
||||
/// CID of each glyph.
|
||||
cids: Vec<u16>,
|
||||
table: hayro_interpret::hayro_cmap::CMap,
|
||||
}
|
||||
|
||||
impl CidText {
|
||||
fn of(data: &[u8]) -> Option<Self> {
|
||||
pub(crate) fn of(data: &[u8]) -> Option<Self> {
|
||||
// An embedded CFF of a few CJK glyphs is kilobytes; whole fonts are some megabytes.
|
||||
if data.len() > 64 << 20 {
|
||||
return None;
|
||||
|
|
@ -283,20 +267,12 @@ impl CidText {
|
|||
/// tables of some PDFs map 龍 to the radical ⿓, which looks the same but isn't the character.
|
||||
const KANGXI: &str = "一丨丶丿乙亅二亠人儿入八冂冖冫几凵刀力勹匕匚匸十卜卩厂厶又口囗土士夂夊夕大女子宀寸小尢尸屮山巛工己巾干幺广廴廾弋弓彐彡彳心戈戶手支攴文斗斤方无日曰月木欠止歹殳毋比毛氏气水火爪父爻爿片牙牛犬玄玉瓜瓦甘生用田疋疒癶白皮皿目矛矢石示禸禾穴立竹米糸缶网羊羽老而耒耳聿肉臣自至臼舌舛舟艮色艸虍虫血行衣襾見角言谷豆豕豸貝赤走足身車辛辰辵邑酉釆里金長門阜隶隹雨靑非面革韋韭音頁風飛食首香馬骨高髟鬥鬯鬲鬼魚鳥鹵鹿麥麻黃黍黑黹黽鼎鼓鼠鼻齊齒龍龜龠";
|
||||
|
||||
/// Is the PDF's glyph `o` for `text` the installed `face`'s glyph for it? The outlines' bounds must
|
||||
/// agree within a tenth of an em: a different font under the same PostScript name, or a wrong
|
||||
/// ToUnicode entry, would open as other letters. Ligatures (several characters) aren't checked,
|
||||
/// nor CJK punctuation set vertically (its vertical form differs from the installed glyph).
|
||||
/// How far glyph `o` (the PDF's, for `text`) is from the installed `face`'s glyph for it: the
|
||||
/// largest difference between their boxes, in thousandths of an em. `None` when they are clearly
|
||||
/// different glyphs (or the face lacks the character); `Some(0)` when there is nothing to compare
|
||||
/// (several characters, a vertical form, no ink in either).
|
||||
fn glyph_deviation(face: &vectorcraft_text::FontFace, text: &str, o: &hayro_interpret::font::OutlineGlyph, vertical: bool) -> Option<f64> {
|
||||
let mut chars = text.chars();
|
||||
let (Some(c), None) = (chars.next(), chars.next()) else { return Some(0.0) };
|
||||
if vertical && matches!(c as u32, 0x2014 | 0x2015 | 0x2025 | 0x2026 | 0x3000..=0x303F | 0x30FC | 0xFE30..=0xFE4F | 0xFF00..=0xFF65) {
|
||||
return Some(0.0);
|
||||
}
|
||||
let Some(c) = comparable(text, vertical) else { return Some(0.0) };
|
||||
let gid = face.glyph_for(c);
|
||||
if gid == 0 {
|
||||
return None;
|
||||
|
|
@ -346,6 +322,17 @@ fn matching_face(
|
|||
best.map(|(_, f)| f)
|
||||
}
|
||||
|
||||
/// The character of glyph `text` when its outline can be compared with an installed font's: a
|
||||
/// single character, and not CJK punctuation set vertically (its vertical form differs).
|
||||
fn comparable(text: &str, vertical: bool) -> Option<char> {
|
||||
let mut chars = text.chars();
|
||||
let (Some(c), None) = (chars.next(), chars.next()) else { return None };
|
||||
if vertical && matches!(c as u32, 0x2014 | 0x2015 | 0x2025 | 0x2026 | 0x3000..=0x303F | 0x30FC | 0xFE30..=0xFE4F | 0xFF00..=0xFF65) {
|
||||
return None;
|
||||
}
|
||||
Some(c)
|
||||
}
|
||||
|
||||
pub(crate) fn unify_radical(c: char) -> char {
|
||||
let i = (c as u32).wrapping_sub(0x2F00);
|
||||
if i < 214 { KANGXI.chars().nth(i as usize).unwrap_or(c) } else { c }
|
||||
|
|
@ -356,8 +343,6 @@ pub(crate) fn unify_radical(c: char) -> char {
|
|||
struct FontInfo {
|
||||
family: String,
|
||||
style: String,
|
||||
/// Available (installed or loaded).
|
||||
found: bool,
|
||||
/// The installed face of its PostScript name.
|
||||
face: Option<Arc<vectorcraft_text::FontFace>>,
|
||||
}
|
||||
|
|
@ -459,11 +444,9 @@ struct Builder<'p> {
|
|||
/// Font (cache key) → its characters by glyph, for CID-keyed fonts embedded without a
|
||||
/// ToUnicode map.
|
||||
cid_text: HashMap<u128, Option<Arc<CidText>>>,
|
||||
/// A line in a font that isn't installed: its glyphs keep their outlines, and this editable
|
||||
/// copy goes to the page's non-printing layer ([`COPIES_LAYER`]).
|
||||
copy_line: Option<TextLine>,
|
||||
/// The page's editable copies.
|
||||
copies: Vec<Arc<Node>>,
|
||||
/// Font (cache key) → the installed face that draws its glyphs, decided from its first glyph
|
||||
/// that can be compared ([`matching_face`]); `None`: its glyphs differ, so it stays outlines.
|
||||
matched: HashMap<u128, Option<Arc<vectorcraft_text::FontFace>>>,
|
||||
/// The soft mask of the graphics state, the art drawn through it so far, and the masks read.
|
||||
mask: Option<Arc<MaskSpec>>,
|
||||
masked: Vec<Arc<Node>>,
|
||||
|
|
@ -578,8 +561,7 @@ impl<'p> Builder<'p> {
|
|||
families: None,
|
||||
missing_fonts: vec![],
|
||||
cid_text: HashMap::new(),
|
||||
copy_line: None,
|
||||
copies: vec![],
|
||||
matched: HashMap::new(),
|
||||
mask: None,
|
||||
masked: vec![],
|
||||
masks: HashMap::new(),
|
||||
|
|
@ -632,8 +614,6 @@ impl<'p> Builder<'p> {
|
|||
self.blend = BlendMode::Normal;
|
||||
self.glyphs = None;
|
||||
self.text = None;
|
||||
self.copy_line = None;
|
||||
self.copies.clear();
|
||||
self.mask = None;
|
||||
self.masked.clear();
|
||||
self.marked.clear();
|
||||
|
|
@ -658,7 +638,6 @@ impl<'p> Builder<'p> {
|
|||
/// Finish the open runs and frames → the root frame's children.
|
||||
fn close_frames(&mut self) -> Vec<Arc<Node>> {
|
||||
self.flush();
|
||||
self.flush_copy();
|
||||
while self.stack.len() > 1 {
|
||||
self.pop_frame();
|
||||
}
|
||||
|
|
@ -1016,23 +995,18 @@ impl<'p> Builder<'p> {
|
|||
});
|
||||
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(), found: true, face: Some(face) },
|
||||
(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, weight, italic);
|
||||
// Not installed: named by its PostScript name, which finds the font once it is.
|
||||
let (family, style) = match (&ps, found) {
|
||||
(Some(ps), false) if !ps.is_empty() => (ps.clone(), "Regular".to_string()),
|
||||
_ => (family, style),
|
||||
};
|
||||
if !found && !family.is_empty() && !self.missing_fonts.contains(&family) {
|
||||
self.missing_fonts.push(family.clone());
|
||||
}
|
||||
FontInfo { family, style, found, face: None }
|
||||
FontInfo { family, style, face: None }
|
||||
}
|
||||
(None, None) => {
|
||||
let d = vectorcraft_doc::CharStyle::default();
|
||||
FontInfo { family: d.font_family, style: d.font_style, found: true, face: None }
|
||||
FontInfo { family: d.font_family, style: d.font_style, face: None }
|
||||
}
|
||||
};
|
||||
self.font_names.insert(key, n.clone());
|
||||
|
|
@ -1053,16 +1027,20 @@ impl<'p> Builder<'p> {
|
|||
return false;
|
||||
}
|
||||
let key = o.font_cache_key();
|
||||
let unicode = o.as_unicode().or_else(|| {
|
||||
let cid = self.cid_text.entry(key).or_insert_with(|| o.font_data().and_then(|d| CidText::of(d.data.as_ref().as_ref())).map(Arc::new));
|
||||
cid.as_ref().and_then(|c| c.unicode(o.glyph_id().to_u32()))
|
||||
});
|
||||
let (unicode, from_cid) = match o.as_unicode() {
|
||||
Some(u) => (Some(u), false),
|
||||
None => {
|
||||
let cid = self.cid_text.entry(key).or_insert_with(|| o.font_data().and_then(|d| CidText::of(d.data.as_ref().as_ref())).map(Arc::new));
|
||||
(cid.as_ref().and_then(|c| c.unicode(o.glyph_id().to_u32())), true)
|
||||
}
|
||||
};
|
||||
let text: String = match unicode {
|
||||
Some(BfString::Char(c)) => c.to_string(),
|
||||
Some(BfString::String(s)) => s,
|
||||
None => return false,
|
||||
};
|
||||
let text: String = text.chars().map(unify_radical).collect();
|
||||
// Adobe's CID tables map some ideographs to their look-alike Kangxi radicals.
|
||||
let text: String = if from_cid { text.chars().map(unify_radical).collect() } else { text };
|
||||
let Some(at) = Placement::of(m) else { return false };
|
||||
if text.is_empty() || text.chars().any(|c| c.is_control()) {
|
||||
return false;
|
||||
|
|
@ -1073,11 +1051,22 @@ impl<'p> Builder<'p> {
|
|||
let top = m * kurbo::Point::new(f64::from(width.filter(|w| *w > 1.0).unwrap_or(1000.0)) * 0.5, 880.0);
|
||||
let mut info = self.font_name(key, o);
|
||||
if let Some(face) = &info.face {
|
||||
let Some(f) = matching_face(face, &text, o, upright) else {
|
||||
let decided = match self.matched.get(&key) {
|
||||
Some(m) => m.clone(),
|
||||
// A glyph that can't be compared (several characters, a vertical form) decides
|
||||
// nothing: it takes the face of its name until one that can be compared does.
|
||||
None if comparable(&text, upright).is_none() => Some(face.clone()),
|
||||
None => {
|
||||
let m = matching_face(face, &text, o, upright);
|
||||
self.matched.insert(key, m.clone());
|
||||
m
|
||||
}
|
||||
};
|
||||
let Some(f) = decided else {
|
||||
self.warn("text whose glyphs differ from the installed font of the same name was kept as outlines");
|
||||
return false;
|
||||
};
|
||||
info = FontInfo { family: f.family.clone(), style: f.style.clone(), found: true, face: Some(f) };
|
||||
info = FontInfo { family: f.family.clone(), style: f.style.clone(), face: Some(f) };
|
||||
}
|
||||
let (paint, opacity) = self.paint(paint, stroke.is_some());
|
||||
let stroke = stroke.map(|p| (paint.clone(), p.line_width as f64 * scale));
|
||||
|
|
@ -1094,24 +1083,6 @@ impl<'p> Builder<'p> {
|
|||
let place = |line: &mut TextLine| {
|
||||
if upright { line.push_upright(&look, at, opacity, Upright { top }, &text) } else { line.push(&look, at, opacity, advance, &text) }
|
||||
};
|
||||
if !info.found {
|
||||
// Not installed: the outlines are what prints; an editable copy goes over them on a
|
||||
// non-printing layer, one per glyph: a fill-and-stroke glyph's stroke pass adds nothing
|
||||
// to it, a stroke-only glyph gets one too.
|
||||
if look.fill.is_none()
|
||||
&& let (Some(s), Some(line)) = (&look.stroke, &mut self.copy_line)
|
||||
&& line.stroke_last(key, at, s.clone())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !self.copy_line.as_mut().is_some_and(place) {
|
||||
self.flush_copy();
|
||||
let mut line = TextLine::new(at, opacity);
|
||||
place(&mut line);
|
||||
self.copy_line = Some(line);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
self.flush_glyphs();
|
||||
// A stroke over the glyph just filled (fill and stroke rendering).
|
||||
if let (Some(s), Some(line)) = (&look.stroke, &mut self.text)
|
||||
|
|
@ -1128,15 +1099,6 @@ impl<'p> Builder<'p> {
|
|||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The editable copy of a line in a missing font, in pink, to the page's copies.
|
||||
fn flush_copy(&mut self) {
|
||||
let Some(mut line) = self.copy_line.take() else { return };
|
||||
line.recolor(&Paint::solid(Color::rgb(1.0, 0.2, 0.6)));
|
||||
let Some((t, _)) = line.finish() else { return };
|
||||
let n = Node::new(self.id(), NodeKind::Text(Box::new(t)));
|
||||
self.copies.push(Arc::new(n));
|
||||
}
|
||||
}
|
||||
|
||||
fn rgba_png(rgba: Vec<u8>, w: u32, h: u32) -> Option<Vec<u8>> {
|
||||
|
|
|
|||
|
|
@ -247,14 +247,6 @@ impl TextLine {
|
|||
true
|
||||
}
|
||||
|
||||
/// Paint every run's fill with `paint` (editable copies of text in missing fonts).
|
||||
pub fn recolor(&mut self, paint: &Paint) {
|
||||
for (look, _) in &mut self.runs {
|
||||
look.fill = Some(paint.clone());
|
||||
look.stroke = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The point type object and its opacity.
|
||||
pub fn finish(mut self) -> Option<(TextObject, f32)> {
|
||||
// Turned along a curve: type on a path through the glyphs.
|
||||
|
|
@ -293,16 +285,20 @@ impl TextLine {
|
|||
// 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);
|
||||
let natural: f64 = vectorcraft_text::layout(db, &t).glyphs.iter().map(|g| g.advance).sum();
|
||||
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;
|
||||
}
|
||||
// Tracked: laid out again for its bounds.
|
||||
bounds = vectorcraft_text::layout(db, &t).bounds;
|
||||
}
|
||||
t.cached_bounds = Some(vectorcraft_text::layout(db, &t).bounds);
|
||||
t.cached_bounds = Some(bounds);
|
||||
Some((t, self.opacity))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,31 @@ fn letter_spacing_in_the_pdf_becomes_tracking() {
|
|||
assert!((ink_width(&t[0]) - expected).abs() < 0.3, "{} vs {expected}", ink_width(&t[0]));
|
||||
}
|
||||
|
||||
/// Embedded font data is untrusted: anything that isn't a CID-keyed CFF is no table, never a panic.
|
||||
#[test]
|
||||
fn cid_tables_ignore_fonts_that_are_not_cid_keyed_cff() {
|
||||
assert!(crate::import::CidText::of(&[]).is_none());
|
||||
assert!(crate::import::CidText::of(b"not a font at all").is_none());
|
||||
let mut x: u32 = 0x9E37_79B9;
|
||||
for len in [4usize, 64, 1024, 65_536] {
|
||||
let junk: Vec<u8> = (0..len)
|
||||
.map(|_| {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 17;
|
||||
x ^= x << 5;
|
||||
x as u8
|
||||
})
|
||||
.collect();
|
||||
assert!(crate::import::CidText::of(&junk).is_none(), "{len} bytes");
|
||||
// A CFF header in front of the junk.
|
||||
let mut cff = vec![1, 0, 4, 4];
|
||||
cff.extend_from_slice(&junk);
|
||||
let _ = crate::import::CidText::of(&cff);
|
||||
}
|
||||
let sans = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/SourceSans3-Regular.ttf")).unwrap();
|
||||
assert!(crate::import::CidText::of(&sans).is_none(), "a TrueType font has no CID charset");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kangxi_radicals_from_cid_tables_become_ideographs() {
|
||||
assert_eq!(crate::import::unify_radical('\u{2FD3}'), '龍');
|
||||
|
|
@ -148,75 +173,29 @@ fn uninstalled_font() -> Vec<u8> {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn text_in_a_missing_font_is_outlined_with_a_pink_editable_copy_that_does_not_print() {
|
||||
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();
|
||||
let font = Font::new(uninstalled_font().into(), 0).unwrap();
|
||||
s.draw_text(KPoint::from_xy(20.0, 50.0), font, 24.0, "Gagaku", false, TextDirection::Auto);
|
||||
s.finish();
|
||||
page.finish();
|
||||
let r = import_with_report(&pdf.finish().unwrap(), &ImportOptions::default()).unwrap();
|
||||
let d = &r.document;
|
||||
assert_eq!(d.layers.len(), 2, "the page and its copies");
|
||||
// The page keeps the outlines.
|
||||
let page_layer = &d.layers[0];
|
||||
let mut outlines = 0;
|
||||
page_layer.walk(&mut |n| outlines += usize::from(n.name.as_deref() == Some("<Text Outlines>")));
|
||||
assert!(outlines > 0);
|
||||
// Over it, a non-printing layer of editable copies, named by the font's PostScript name.
|
||||
let copies = &d.layers[1];
|
||||
let NodeKind::Layer { printable, .. } = &copies.kind else { panic!() };
|
||||
assert!(!printable && copies.name.as_deref().unwrap().starts_with(crate::import::COPIES_LAYER));
|
||||
let mut t = vec![];
|
||||
copies.walk(&mut |n| {
|
||||
if let NodeKind::Text(x) = &n.kind {
|
||||
t.push((**x).clone());
|
||||
fn text_in_a_missing_font_stays_live_type() {
|
||||
for stroke_only in [false, true] {
|
||||
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();
|
||||
let font = Font::new(uninstalled_font().into(), 0).unwrap();
|
||||
if stroke_only {
|
||||
s.set_fill(None);
|
||||
s.set_stroke(Some(krilla::paint::Stroke::default()));
|
||||
}
|
||||
});
|
||||
assert_eq!(t.len(), 1);
|
||||
assert_eq!(t[0].plain_text(), "Gagaku");
|
||||
let st = t[0].first_style();
|
||||
assert_eq!(st.font_family, "SourceSans9-Regular");
|
||||
assert_eq!(st.fill, vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.2, 0.6)));
|
||||
assert!(r.warnings.iter().any(|w| w.contains("SourceSans9-Regular") && w.contains("editable copies")), "{:?}", r.warnings);
|
||||
// Exports leave the non-printing copies out (compared with the copies made printable).
|
||||
let count = |d: &Document| {
|
||||
let bytes = crate::export(d, &Default::default()).unwrap();
|
||||
let back = import_with_report(&bytes, &ImportOptions::default()).unwrap().document;
|
||||
let mut n = 0;
|
||||
back.walk(|x| n += usize::from(!x.is_container()));
|
||||
n
|
||||
};
|
||||
let mut printed = d.clone();
|
||||
if let Some(l) = printed.layers.get_mut(1).map(std::sync::Arc::make_mut)
|
||||
&& let NodeKind::Layer { printable, .. } = &mut l.kind
|
||||
{
|
||||
*printable = true;
|
||||
s.draw_text(KPoint::from_xy(20.0, 50.0), font, 24.0, "Gagaku", false, TextDirection::Auto);
|
||||
s.finish();
|
||||
page.finish();
|
||||
let r = import_with_report(&pdf.finish().unwrap(), &ImportOptions::default()).unwrap();
|
||||
assert_eq!(r.document.layers.len(), 1, "no extra layers");
|
||||
let t = texts(&r.document);
|
||||
assert_eq!(t.len(), 1, "stroke only {stroke_only}");
|
||||
assert_eq!(t[0].plain_text(), "Gagaku");
|
||||
// Named by the family its PostScript name reads as, so it picks the font up once installed.
|
||||
let family = t[0].first_style().font_family.clone();
|
||||
assert_eq!(family, "Source Sans 9");
|
||||
assert!(r.warnings.iter().any(|w| w.contains(family.as_str()) && w.contains("fallback font")), "{:?}", r.warnings);
|
||||
}
|
||||
assert!(count(d) < count(&printed), "{} vs {}", count(d), count(&printed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_only_text_in_a_missing_font_gets_an_editable_copy_too() {
|
||||
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();
|
||||
let font = Font::new(uninstalled_font().into(), 0).unwrap();
|
||||
s.set_fill(None);
|
||||
s.set_stroke(Some(krilla::paint::Stroke::default()));
|
||||
s.draw_text(KPoint::from_xy(20.0, 50.0), font, 24.0, "Gagaku", false, TextDirection::Auto);
|
||||
s.finish();
|
||||
page.finish();
|
||||
let d = import_with_report(&pdf.finish().unwrap(), &ImportOptions::default()).unwrap().document;
|
||||
let copies = d.layers.get(1).expect("a layer of editable copies");
|
||||
let mut t = vec![];
|
||||
copies.walk(&mut |n| {
|
||||
if let NodeKind::Text(x) = &n.kind {
|
||||
t.push(x.plain_text());
|
||||
}
|
||||
});
|
||||
assert_eq!(t, ["Gagaku"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Reference in New Issue