mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 02:11:07 -06:00
Merge upstream/main into main preserving custom features
- Resolve all 13 merge conflicts between upstream/main and main - Preserve full image editing suite: OBB, crop, proportional resize, arrange, and context menu actions - Preserve text editing formatting tools: bullet/numbered lists, subscript/superscript, and line spacing - Cleanly integrate upstream's canvas modularization, font updates, and styling changes - Refactor arrange_image and run_edit_page_image helpers to satisfy clippy cognitive complexity limits - All engine, edit, and UI integration test suites passing cleanly
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
use super::core::{limited_pdf_string, pretty_date, spend};
|
||||
use super::model::{Annotation, AttachmentSource, DestView, Field, FieldKind, LayerOp, Link, LinkTarget, RemoteDestination, link_replies};
|
||||
use super::navigation::text_string;
|
||||
use super::*;
|
||||
use super::{Dictionary, DocInfo, Entry, HashMap, HashSet, Inspector, Object, ObjectId};
|
||||
|
||||
/// At most this many `/State` entries of a set-layer-visibility action are read.
|
||||
const MAX_LAYER_STATE: usize = 1024;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use super::annotations::MAX_LINK_ATTACHMENT_TEXT_BYTES;
|
||||
use super::model::{AttachmentSource, PageInfo, Xfa};
|
||||
use super::navigation::text_string;
|
||||
use super::*;
|
||||
use super::{
|
||||
Arc, AssertUnwindSafe, DecryptionError, Dictionary, DocInfo, Document, HashMap, HashSet, LazyStructure, LoadOptions, LoadPdfError, Object,
|
||||
ObjectId, OnceCell, OpenError, Pdf, Structure, catch_unwind,
|
||||
};
|
||||
|
||||
/// lopdf decodes object and cross-reference streams while it loads, with no limit unless one is
|
||||
/// set: a few hundred bytes of nested FlateDecode then inflate to gigabytes. Real object and
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::HashMap;
|
||||
|
||||
/// What kind of XFA form a document has (`/AcroForm /XFA`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
use super::core::inspect;
|
||||
use super::core::spend;
|
||||
use super::model::{DestView, OutlineItem, PageInfo};
|
||||
use super::*;
|
||||
use super::{
|
||||
BTreeMap, Dictionary, HashMap, HashSet, Inspector, MAX_LABEL_BYTES, MAX_LABEL_TOTAL_BYTES, MAX_LABEL_TREE_DEPTH, MAX_LABEL_TREE_WORK,
|
||||
MAX_PREFIX_BYTES, MAX_PREFIX_TOTAL_BYTES, Object, ObjectId, alpha, roman,
|
||||
};
|
||||
|
||||
/// `DocInfo::outline` holds the first this many bookmarks in outline order. Opening a document
|
||||
/// reads only this window; the rest are listed by page through `pdfcraft_organize`.
|
||||
|
||||
@@ -3,7 +3,7 @@ use super::core::inspect;
|
||||
use super::core::{Inspector, load_options};
|
||||
use super::model::{Attachment, AttachmentSource, FontInfo, Layer, PageTransition, TransitionStyle};
|
||||
use super::navigation::NameTreeSeen;
|
||||
use super::*;
|
||||
use super::{AssertUnwindSafe, Dictionary, DocInfo, Document, HashSet, Object, ObjectId, catch_unwind};
|
||||
|
||||
/// Decoded bytes read from an XFA form's configuration packet (or a whole-XDP `/XFA` stream) to
|
||||
/// find `dynamicRender`. Designer's packets are a few kilobytes; a whole XDP can hold the template.
|
||||
|
||||
@@ -13,6 +13,7 @@ mod pixels;
|
||||
mod raster;
|
||||
mod reading;
|
||||
mod structure;
|
||||
mod system_font;
|
||||
pub mod text;
|
||||
pub mod transition;
|
||||
|
||||
|
||||
@@ -78,15 +78,36 @@ pub struct RenderConfig {
|
||||
/// The page clears to transparent instead of white (or the reading background): for a page
|
||||
/// holding only what is laid over another raster, such as a comment while it is dragged.
|
||||
pub transparent: bool,
|
||||
/// Draw a CID font of an Adobe CJK collection that the PDF does not embed with one face already
|
||||
/// installed on this machine (see `system_font`). Off by default and on only for on-screen
|
||||
/// viewer and preview rasters: exports, printing and OCR render without it, so nothing drawn with an
|
||||
/// installed font is saved or printed (AGENTS.md §1.2, §1.4).
|
||||
pub system_fonts: bool,
|
||||
}
|
||||
|
||||
impl RenderConfig {
|
||||
/// This configuration for a raster that is only shown on screen (the viewer, Combine
|
||||
/// thumbnails, figure previews): installed CJK faces may draw fonts the PDF doesn't embed.
|
||||
/// Never use it for anything that can be saved, printed, recognised or returned to an agent
|
||||
/// (AGENTS.md §1.2, §1.4).
|
||||
#[must_use]
|
||||
pub fn on_screen(self) -> Self {
|
||||
Self { system_fonts: true, ..self }
|
||||
}
|
||||
|
||||
/// Build interpreter settings with layer and comment preferences and a font resolver that
|
||||
/// tries bundled Japanese faces, then opt-in installed CJK faces, then standard substitutes.
|
||||
/// Passwords belong to parser construction; request-specific preview overrides are applied
|
||||
/// by `render_page`, which also preserves the caller's warning and cancellation hooks.
|
||||
fn settings(&self) -> InterpreterSettings {
|
||||
let standard = InterpreterSettings::default().font_resolver;
|
||||
let system_fonts = self.system_fonts;
|
||||
InterpreterSettings {
|
||||
ocg_overrides: self.layers.clone(),
|
||||
hide_comments: self.hide_comments,
|
||||
font_resolver: Arc::new(move |query| japanese_fallback(query).or_else(|| standard(query))),
|
||||
font_resolver: Arc::new(move |query| {
|
||||
japanese_fallback(query).or_else(|| system_fonts.then(|| system_fallback(query)).flatten()).or_else(|| standard(query))
|
||||
}),
|
||||
..InterpreterSettings::default()
|
||||
}
|
||||
}
|
||||
@@ -119,6 +140,14 @@ fn japanese_fallback(query: &FontQuery) -> Option<(FontData, u32)> {
|
||||
Some((Arc::new(face.bytes), 0))
|
||||
}
|
||||
|
||||
/// An installed face for a CID font of an Adobe CJK collection that the PDF doesn't embed and
|
||||
/// craft-fonts has no face for (Traditional and Simplified Chinese, Korean, and Japanese in a
|
||||
/// build without craft-fonts). `None` for every other font.
|
||||
fn system_fallback(query: &FontQuery) -> Option<(FontData, u32)> {
|
||||
let FontQuery::Fallback(f) = query else { return None };
|
||||
crate::system_font::fallback(f)
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum JapaneseFace {
|
||||
Mincho,
|
||||
@@ -182,6 +211,7 @@ pub struct RenderRequest {
|
||||
/// The tag bit of a print-preview raster. Such requests always render the document's true
|
||||
/// colours, ignoring `RenderConfig::reading_colors`: a preview's job is fidelity to the
|
||||
/// printout, and the print itself — imposed from the document — is never recoloured.
|
||||
/// Installed-font fallback is also disabled, matching the output's font policy.
|
||||
pub const PRINT_TAG: u64 = 1 << 60;
|
||||
|
||||
/// The reading colours a request renders with: print previews ([`PRINT_TAG`]) ignore the
|
||||
@@ -319,6 +349,23 @@ fn render_page<'a>(
|
||||
config: &RenderConfig,
|
||||
req: RenderRequest,
|
||||
) -> Result<Output, (String, bool)> {
|
||||
// Print previews share the viewer pool, but must use the output font policy. Keep the
|
||||
// caller's cancellation/warning hooks, layers and annotation settings. A fresh cache is
|
||||
// essential: hayro keys cached fonts by PDF dictionary, not by the font resolver, so the
|
||||
// viewer cache could supply an installed face even after changing the resolver (and a
|
||||
// cached missing font from print preview could blank the viewer on switching back).
|
||||
let print_settings;
|
||||
let print_cache;
|
||||
let (settings, cache) = if config.system_fonts && req.tag & PRINT_TAG != 0 {
|
||||
print_settings = InterpreterSettings {
|
||||
font_resolver: RenderConfig { system_fonts: false, ..config.clone() }.settings().font_resolver,
|
||||
..settings.clone()
|
||||
};
|
||||
print_cache = RenderCache::new();
|
||||
(&print_settings, &print_cache)
|
||||
} else {
|
||||
(settings, cache)
|
||||
};
|
||||
let reading = request_reading(config, &req);
|
||||
if req.kind == RequestKind::Text {
|
||||
return match catch_unwind(AssertUnwindSafe(|| crate::text::extract_page(pdf, req.page, settings))) {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Arc, AssertUnwindSafe, AtomicRenderStats, InterpreterSettings, JoinHandle, Mutex, Ordering, PageRenderer, Pdf, Pixels, RenderCache, RenderConfig,
|
||||
RenderRequest, RenderStats, RenderedPage, RequestFailureKey, RequestKind, ResultReceiver, Stopwatch, SyncSender, WorkQueue, catch_unwind, finish,
|
||||
panic_message, parse, render_page, take_warnings, warning_settings,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use super::{AtomicBool, Receiver, sync_channel};
|
||||
|
||||
/// How long one page may render before the pool gives up on it (the watchdog).
|
||||
pub const STUCK_AFTER: std::time::Duration = std::time::Duration::from_secs(20);
|
||||
|
||||
@@ -7004,6 +7004,10 @@ mod default_colour_spaces;
|
||||
// (vendored hayro-syntax patch 28, hayro-interpret patch 55).
|
||||
mod devicen_inks;
|
||||
|
||||
// #3776: isolated DeviceCMYK groups blend in CMYK (vendored hayro-interpret patch 70, hayro
|
||||
// patch 27).
|
||||
mod isolated_cmyk_groups;
|
||||
|
||||
/// #3109 / #3116: clipping-only text (`7 Tr`) installs a clip for subsequent painting
|
||||
/// but draws no pixels itself.
|
||||
#[test]
|
||||
@@ -7035,3 +7039,246 @@ fn test_clip_only_text_raster_preserves_clipping_without_painting() {
|
||||
assert!(pixels.contains(&[0, 0, 255, 255]), "fill shows through the 7 Tr clip");
|
||||
assert!(pixels.contains(&white), "the clip is text-shaped, not the whole page");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod gbk_glyphs;
|
||||
|
||||
/// Chinese and Korean CID fonts that aren't embedded (Adobe-CNS1 `MSung-Light`, Adobe-GB1
|
||||
/// `STSong-Light`, Adobe-Korea1 `HYGoThic-Medium`; Acrobat also writes Japanese `KozGoPr6N`
|
||||
/// text boxes without the font) drew nothing, because craft-fonts has only Japanese faces.
|
||||
/// They now draw in a face already installed on the machine, read at runtime. A machine
|
||||
/// without such a face draws nothing, as before, so each check is skipped there.
|
||||
#[test]
|
||||
fn non_embedded_chinese_and_korean_cid_fonts_draw_with_an_installed_face() {
|
||||
use hayro::hayro_interpret::font::FallbackFontQuery;
|
||||
use hayro::hayro_interpret::hayro_cmap::CharacterCollection;
|
||||
// (base font, collection, ordering, encoding, encoded text, descendant subtype)
|
||||
let cases = [
|
||||
("MSung-Light", CidFamily::AdobeCNS1, "CNS1", "UniCNS-UCS2-H", "7E419AD45B57", "CIDFontType0"), // 繁體字
|
||||
("STSong-Light", CidFamily::AdobeGB1, "GB1", "UniGB-UCS2-H", "72698D448DF7", "CIDFontType0"), // 物资跷
|
||||
("HYGoThic-Medium", CidFamily::AdobeKorea1, "Korea1", "UniKS-UCS2-H", "D55CAE00", "CIDFontType0"), // 한글
|
||||
// The real Windows book uses non-embedded TrueType CID fonts and a legacy GBK CMap,
|
||||
// without ToUnicode. These bytes encode 中文; they are not Unicode code points.
|
||||
("SimSun", CidFamily::AdobeGB1, "GB1", "GBK-EUC-H", "D6D0CEC4", "CIDFontType2"),
|
||||
("SimHei", CidFamily::AdobeGB1, "GB1", "GBK-EUC-H", "D6D0CEC4", "CIDFontType2"),
|
||||
("KaiTi", CidFamily::AdobeGB1, "GB1", "GBK-EUC-H", "D6D0CEC4", "CIDFontType2"),
|
||||
("FangSong", CidFamily::AdobeGB1, "GB1", "GBK-EUC-H", "D6D0CEC4", "CIDFontType2"),
|
||||
("ABCDEF+FZSSK--GBK1-0", CidFamily::AdobeGB1, "GB1", "GBK-EUC-H", "D6D0CEC4", "CIDFontType2"),
|
||||
("ABCDEF+FZHTK--GBK1-0", CidFamily::AdobeGB1, "GB1", "GBK-EUC-H", "D6D0CEC4", "CIDFontType2"),
|
||||
("ABCDEF+FZKTK--GBK1-0", CidFamily::AdobeGB1, "GB1", "GBK-EUC-H", "D6D0CEC4", "CIDFontType2"),
|
||||
("ABCDEF+FZFSK--GBK1-0", CidFamily::AdobeGB1, "GB1", "GBK-EUC-H", "D6D0CEC4", "CIDFontType2"),
|
||||
];
|
||||
for (base_font, family, ordering, encoding, code, subtype) in cases {
|
||||
let query = FontQuery::Fallback(FallbackFontQuery {
|
||||
post_script_name: Some(base_font.into()),
|
||||
character_collection: Some(CharacterCollection { family, supplement: 2 }),
|
||||
..FallbackFontQuery::default()
|
||||
});
|
||||
if super::system_fallback(&query).is_none() {
|
||||
eprintln!("no installed face for {base_font} (or PDFCRAFT_SYSTEM_FONTS=0): skipped");
|
||||
continue;
|
||||
}
|
||||
let content = format!("BT /F1 40 Tf 5 15 Td <{code}> Tj ET");
|
||||
let pdf = format!(
|
||||
"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 60] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj
|
||||
4 0 obj << /Length {} >> stream
|
||||
{content}
|
||||
endstream endobj
|
||||
5 0 obj << /Type /Font /Subtype /Type0 /BaseFont /{base_font} /Encoding /{encoding} /DescendantFonts [6 0 R] >> endobj
|
||||
6 0 obj << /Type /Font /Subtype /{subtype} /BaseFont /{base_font} /CIDSystemInfo << /Registry (Adobe) /Ordering ({ordering}) /Supplement 2 >>
|
||||
/FontDescriptor 7 0 R /DW 1000 >> endobj
|
||||
7 0 obj << /Type /FontDescriptor /FontName /{base_font} /Flags 6 /FontBBox [0 -141 1000 859] /ItalicAngle 0 /Ascent 859 /Descent -141 /CapHeight 700 /StemV 80 >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF",
|
||||
content.len()
|
||||
);
|
||||
let pdf = Arc::new(pdf.into_bytes());
|
||||
let ink = |config: RenderConfig| {
|
||||
let mut r = PageRenderer::new(pdf.clone(), config);
|
||||
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
|
||||
assert!(p.error.is_none(), "{base_font}: {:?}", p.error);
|
||||
p.rgba.as_chunks::<4>().0.iter().filter(|c| c[0] < 128).count()
|
||||
};
|
||||
// Two to four glyphs at 40 pt cover several hundred dark pixels; a blank run has none.
|
||||
let shown = ink(RenderConfig { system_fonts: true, ..RenderConfig::default() });
|
||||
assert!(shown > 300, "{base_font}: the text is drawn ({shown} dark pixels)");
|
||||
// Off by default: exports, printing and OCR render without an installed font, so
|
||||
// nothing drawn with one can be saved (AGENTS.md §1.2, §1.4).
|
||||
let saved = ink(RenderConfig::default());
|
||||
assert!(saved < 50, "{base_font}: not drawn without system_fonts ({saved} dark pixels)");
|
||||
if encoding == "GBK-EUC-H" {
|
||||
#[cfg(target_os = "windows")]
|
||||
gbk_glyphs::assert_expected_glyphs(&pdf, base_font);
|
||||
let mut renderer = PageRenderer::new(pdf, RenderConfig { system_fonts: true, ..Default::default() });
|
||||
let text = renderer.render(RenderRequest { page: 0, kind: RequestKind::Text, ..Default::default() });
|
||||
assert!(text.error.is_none(), "{base_font}: {:?}", text.error);
|
||||
assert_eq!(text.text.unwrap().plain_text().trim(), "中文", "the fallback preserves GBK text mapping");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Print preview must match output even after the same worker cached an installed face.
|
||||
/// Switching back to the viewer must not retain the print resolver's missing-font result.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn print_preview_excludes_installed_fonts_without_poisoning_viewer_cache() {
|
||||
use hayro::hayro_interpret::font::FallbackFontQuery;
|
||||
use hayro::hayro_interpret::hayro_cmap::CharacterCollection;
|
||||
let query = FontQuery::Fallback(FallbackFontQuery {
|
||||
post_script_name: Some("SimSun".into()),
|
||||
character_collection: Some(CharacterCollection { family: CidFamily::AdobeGB1, supplement: 2 }),
|
||||
..Default::default()
|
||||
});
|
||||
if super::system_fallback(&query).is_none() {
|
||||
eprintln!("no installed GB1 face (or PDFCRAFT_SYSTEM_FONTS=0): skipped");
|
||||
return;
|
||||
}
|
||||
let content = "BT /F1 40 Tf 5 15 Td <D6D0CEC4> Tj ET";
|
||||
let bytes = Arc::new(format!(
|
||||
"%PDF-1.7\n\
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 120 60] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj\n\
|
||||
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
|
||||
5 0 obj << /Type /Font /Subtype /Type0 /BaseFont /SimSun /Encoding /GBK-EUC-H /DescendantFonts [6 0 R] >> endobj\n\
|
||||
6 0 obj << /Type /Font /Subtype /CIDFontType2 /BaseFont /SimSun /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 2 >> /FontDescriptor 7 0 R /DW 1000 >> endobj\n\
|
||||
7 0 obj << /Type /FontDescriptor /FontName /SimSun /Flags 6 /FontBBox [0 -141 1000 859] /ItalicAngle 0 /Ascent 859 /Descent -141 /CapHeight 700 /StemV 80 >> endobj\n\
|
||||
trailer << /Root 1 0 R >>\n%%EOF", content.len()
|
||||
).into_bytes());
|
||||
let config = RenderConfig::default().on_screen();
|
||||
let mut inline = PageRenderer::new(bytes.clone(), config.clone());
|
||||
let mut output = PageRenderer::new(bytes.clone(), RenderConfig::default());
|
||||
let pool = RenderPool::new(bytes, 1, config);
|
||||
for tile in [None, Some(Tile { x: 0, y: 0, w: 120, h: 60 })] {
|
||||
// Cover both directions with a warm worker cache, including other caller tag bits.
|
||||
for tag in [PRINT_TAG | 1, 2, PRINT_TAG | 3, 4] {
|
||||
let req = RenderRequest { page: 0, scale: 1.0, tile, tag, ..Default::default() };
|
||||
let truth = output.render(req);
|
||||
assert!(truth.error.is_none(), "{:?}", truth.error);
|
||||
pool.set_queue(vec![req]);
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
let worker = loop {
|
||||
if let Some(page) = pool.try_recv() {
|
||||
break page;
|
||||
}
|
||||
assert!(std::time::Instant::now() < deadline, "print-preview worker timed out");
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
};
|
||||
for page in [inline.render(req), worker] {
|
||||
assert!(page.error.is_none(), "{:?}", page.error);
|
||||
if tag & PRINT_TAG != 0 {
|
||||
assert_eq!(page.rgba, truth.rgba, "print preview must match output, tile={tile:?}");
|
||||
} else {
|
||||
let ink = page.rgba.as_chunks::<4>().0.iter().filter(|c| c[0] < 128).count();
|
||||
assert!(ink > 300, "viewer still draws installed Chinese glyphs: {ink}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A malformed token in a content stream used to end the stream there, so the renderer, text
|
||||
/// extraction, Find and search-based redaction lost the rest of the page while the edit
|
||||
/// reader, MuPDF and pdftotext read it (#3565, #3568). Each token below is now stepped over.
|
||||
#[test]
|
||||
fn a_malformed_token_does_not_end_the_content_stream() {
|
||||
// One token between two text objects. `BEFORE` is drawn before it, `AFTER` after.
|
||||
let page = |token: &str| {
|
||||
let stream = format!("BT /F1 8 Tf 2 30 Td (BEFORE) Tj ET {token} BT /F1 8 Tf 2 10 Td (AFTER) Tj ET");
|
||||
let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 60 40] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >> endobj\n5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj\n".to_vec();
|
||||
pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", stream.len()).as_bytes());
|
||||
pdf.extend_from_slice(stream.as_bytes());
|
||||
pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n");
|
||||
let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
|
||||
let text = renderer.render(RenderRequest { page: 0, kind: RequestKind::Text, ..Default::default() });
|
||||
assert!(text.error.is_none(), "{token}: {:?}", text.error);
|
||||
text.text.expect("a text layer").plain_text()
|
||||
};
|
||||
|
||||
for token in [
|
||||
// A `re` with three operands: the typed iterator used to abort on a path operator it
|
||||
// could not dispatch, which is what #3568 reports.
|
||||
"20 10 10 re",
|
||||
// Eleven operands: under the 256 the stack holds, so this one runs on its own last
|
||||
// operand. Past the limit is covered by
|
||||
// `an_operator_past_the_operand_limit_reads_its_own_operands` below.
|
||||
"0 0 0 0 0 0 0 0 0 0 0 g",
|
||||
// A stray delimiter: the lexer could not read an operator and jumped to the end.
|
||||
")",
|
||||
// The same with no space after it. Stepping over the regular characters that follow a
|
||||
// delimiter ate the next operator, so the page lost its second text object
|
||||
// (#3636 review); only the delimiter is stepped over.
|
||||
")BT ET",
|
||||
// A malformed `#` escape in a name, so neither an object nor an operator read.
|
||||
"/C#2 gs",
|
||||
// And the well-formed forms still work.
|
||||
"20 10 10 5 re n",
|
||||
"0.5 g",
|
||||
] {
|
||||
let text = page(token);
|
||||
assert!(text.contains("BEFORE"), "{token}: {text:?}");
|
||||
assert!(text.contains("AFTER"), "{token}: the rest of the page was lost: {text:?}");
|
||||
assert!(text.find("BEFORE") < text.find("AFTER"), "{token}: the page is read in order: {text:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An operator preceded by more operands than the stack holds is applied to its own operands,
|
||||
/// which are the ones nearest it, rather than to whichever of the strays survived. The parser
|
||||
/// had a branch meant to drop such an instruction whole, dead because `Stack::push` always
|
||||
/// reported success (noted in review of #3636); reviving it loses legitimate content, which
|
||||
/// `an_operator_with_hundreds_of_operands_does_not_end_the_content` measures, so the dead branch
|
||||
/// is gone and this pins what the ring buffer does instead.
|
||||
#[test]
|
||||
fn an_operator_past_the_operand_limit_reads_its_own_operands() {
|
||||
// The strays are `1` and the operator's own operand is `0`, so the two answers differ: white
|
||||
// if the operator read a stray, black if it read its own.
|
||||
let page = |strays: usize| {
|
||||
let stream = format!("{}0 g 0 0 20 20 re f", "1 ".repeat(strays));
|
||||
let pdf = format!(
|
||||
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 20 20] /Contents 4 0 R >> endobj\n4 0 obj << /Length {} >> stream\n{stream}\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF",
|
||||
stream.len()
|
||||
);
|
||||
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
|
||||
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
|
||||
assert!(p.error.is_none(), "{strays} strays: {:?}", p.error);
|
||||
p.rgba.as_chunks::<4>().0.to_vec()
|
||||
};
|
||||
let all_black = |pixels: &[[u8; 4]]| pixels.iter().all(|p| *p == [0, 0, 0, 255]);
|
||||
|
||||
// None, a few, the limit, and past it: the fill is the black the `g` asked for every time.
|
||||
for strays in [0, 5, 255, 256, 257, 600] {
|
||||
assert!(all_black(&page(strays)), "{strays} stray operands: the `g` reads its own operand");
|
||||
}
|
||||
}
|
||||
|
||||
/// Content after an inline image whose data holds `EI` is still read. This pins the property
|
||||
/// but does **not** exercise the look-ahead gating added for #3636's review: accepting a false
|
||||
/// `EI` truncates the image, while the text after the real one is read either way, and this
|
||||
/// assertion passes with the gating removed. Showing the truncation needs valid ASCII85 data
|
||||
/// that contains `EI`, which this fixture does not have.
|
||||
#[test]
|
||||
fn content_after_an_inline_image_holding_ei_is_still_read() {
|
||||
// An ASCII85 image whose data holds `EI\n`, then an `re` with three operands and twenty
|
||||
// `q`s -- enough steps to satisfy the look-ahead's counter if the bad `re` were skipped --
|
||||
// before the real `EI`. `AFTER` is drawn once the image is over.
|
||||
let stream = b"q 100 0 0 100 10 10 cm BI /W 4 /H 4 /CS /G /BPC 8 /F /A85 ID EI
|
||||
20 10 10 re q q q q q q q q q q q q q q q q q q q q EI Q BT /F1 8 Tf 2 4 Td (AFTER) Tj ET"
|
||||
.to_vec();
|
||||
let mut pdf =
|
||||
b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 120 120] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >> endobj\n5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj\n"
|
||||
.to_vec();
|
||||
pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", stream.len()).as_bytes());
|
||||
pdf.extend_from_slice(&stream);
|
||||
pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n");
|
||||
|
||||
let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
|
||||
let text = renderer.render(RenderRequest { page: 0, kind: RequestKind::Text, ..Default::default() });
|
||||
assert!(text.error.is_none(), "{:?}", text.error);
|
||||
let page = text.text.expect("a text layer").plain_text();
|
||||
// The image ran to the real `EI`, so the text after it is the page's only text: the first
|
||||
// `EI` inside the data was not taken for the end.
|
||||
assert!(page.contains("AFTER"), "the content after the image was lost: {page:?}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//! A same-machine pixel oracle, independent of GBK decoding and installed-face selection.
|
||||
//! All font reads and reference rasters stay in memory; nothing is embedded or published.
|
||||
|
||||
use super::*;
|
||||
use hayro::hayro_interpret::font::FontData;
|
||||
use skrifa::MetadataProvider as _;
|
||||
use skrifa::string::StringId;
|
||||
|
||||
/// Compare the real GBK PDF path with explicit Unicode and a known installed face. Unlike an
|
||||
/// ink count or extracted text, this fails if drawing chooses another glyph or font family.
|
||||
pub(super) fn assert_expected_glyphs(pdf: &Arc<Vec<u8>>, base_font: &str) {
|
||||
let (expected, file) = match base_font {
|
||||
"SimSun" | "ABCDEF+FZSSK--GBK1-0" => ("SimSun", "simsun.ttc"),
|
||||
"SimHei" | "ABCDEF+FZHTK--GBK1-0" => ("SimHei", "simhei.ttf"),
|
||||
"KaiTi" | "ABCDEF+FZKTK--GBK1-0" => ("KaiTi", "simkai.ttf"),
|
||||
"FangSong" | "ABCDEF+FZFSK--GBK1-0" => ("FangSong", "simfang.ttf"),
|
||||
other => panic!("no independent GBK oracle for {other}"),
|
||||
};
|
||||
// Do not use the production candidate list, resolver or face-selection helpers here.
|
||||
let directory = std::env::var_os("WINDIR").or_else(|| std::env::var_os("SystemRoot")).unwrap_or_else(|| r"C:\Windows".into());
|
||||
let path = std::path::PathBuf::from(directory).join("Fonts").join(file);
|
||||
if !path.is_file() {
|
||||
eprintln!("{base_font}: exact {expected} face unavailable; pixel oracle skipped");
|
||||
return;
|
||||
}
|
||||
let bytes = Arc::new(std::fs::read(path).expect("read installed reference font"));
|
||||
let index = (0..32)
|
||||
.find(|&index| {
|
||||
skrifa::FontRef::from_index(&bytes, index)
|
||||
.is_ok_and(|font| font.localized_strings(StringId::POSTSCRIPT_NAME).any(|name| name.chars().collect::<String>() == expected))
|
||||
})
|
||||
.expect("reference file contains the expected PostScript face");
|
||||
let face = skrifa::FontRef::from_index(&bytes, index).unwrap();
|
||||
for character in ['\u{4e2d}', '\u{6587}'] {
|
||||
assert!(face.charmap().map(character).is_some_and(|id| id.to_u32() != 0), "{expected} must contain {character}");
|
||||
}
|
||||
|
||||
// Explicit ToUnicode maps arbitrary reference codes to U+4E2D and U+6587, bypassing GBK
|
||||
// and Adobe-GB1 CID lookup. The fixed resolver below bypasses family classification too.
|
||||
let cmap = "begincmap /CMapType 2 def 1 begincodespacerange <0000> <FFFF> endcodespacerange \
|
||||
2 beginbfchar <0001> <4E2D> <0002> <6587> endbfchar endcmap";
|
||||
let source = std::str::from_utf8(pdf).unwrap();
|
||||
assert!(source.contains("/Encoding /GBK-EUC-H") && source.contains("<D6D0CEC4>"));
|
||||
let reference = source
|
||||
.replace("/Encoding /GBK-EUC-H", "/Encoding /Identity-H /ToUnicode 8 0 R")
|
||||
.replace("<D6D0CEC4>", "<00010002>")
|
||||
.replace("trailer <<", &format!("8 0 obj << /Length {} >> stream\n{cmap}\nendstream endobj\ntrailer <<", cmap.len()));
|
||||
let parsed = Pdf::new(Arc::new(reference.into_bytes())).unwrap();
|
||||
let data: FontData = bytes;
|
||||
let settings = InterpreterSettings { font_resolver: Arc::new(move |_| Some((data.clone(), index))), ..Default::default() };
|
||||
let reference = hayro::render(&parsed.pages()[0], &RenderCache::new(), &settings, &RenderSettings { bg_color: WHITE, ..Default::default() });
|
||||
let mut renderer = PageRenderer::new(pdf.clone(), RenderConfig::default().on_screen());
|
||||
let actual = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
|
||||
assert!(actual.error.is_none(), "{base_font}: {:?}", actual.error);
|
||||
let expected_pixels = reference.data_as_u8_slice();
|
||||
assert_eq!(actual.rgba.len(), expected_pixels.len());
|
||||
assert!(expected_pixels.as_chunks::<4>().0.iter().filter(|pixel| pixel[0] < 128).count() > 300, "reference must draw two visible glyphs");
|
||||
assert!(actual.rgba.as_ref() == expected_pixels, "{base_font}: GBK raster must draw the exact {expected} glyphs for U+4E2D U+6587");
|
||||
eprintln!("{base_font}: exact {expected} Unicode-reference pixels matched");
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
//! Isolated transparency groups whose colour space is `DeviceCMYK` blend in CMYK (ISO 32000-2
|
||||
//! §11.3.4, §11.3.5.3).
|
||||
|
||||
use crate::{PageRenderer, RenderConfig, RenderRequest, RenderedPage, RequestKind};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A print test file's patch of blend modes in isolated groups whose colour space is
|
||||
/// `DeviceCMYK` drew an X in nearly every cell. The elements of a group blend in the
|
||||
/// group's colour space, and a subtractive space's components are complemented before the
|
||||
/// blend function and back after it (ISO 32000-2 §11.3.4); of the nonseparable modes,
|
||||
/// §11.3.5.3 blends C, M and Y as complemented R, G and B and takes K from the backdrop
|
||||
/// (from the source for Luminosity). PdfCraft blended the colours converted to RGB, so
|
||||
/// magenta under black with Difference came out magenta instead of green. Vendored
|
||||
/// hayro-interpret patch (70) and hayro patch (27): an isolated group (§11.4.7: its own
|
||||
/// transparent backdrop) blending in DeviceCMYK is blended in CMYK.
|
||||
///
|
||||
/// Each cell is a square painted in the CMYK the blend should give, with a smaller isolated
|
||||
/// group on it whose two elements are tint A and then tint B with the cell's blend mode:
|
||||
/// drawn right, the group is the colour of the square around it.
|
||||
#[test]
|
||||
fn isolated_groups_blend_in_device_cmyk() {
|
||||
// (blend mode, tint A, tint B, A then B blended in DeviceCMYK, worked out by hand)
|
||||
type Cell = (&'static str, [f32; 4], [f32; 4], [f32; 4]);
|
||||
let cells: [Cell; 7] = [
|
||||
// |(1,0,1,1) − (1,1,1,0)| = (0,1,0,1), complemented back: green.
|
||||
("Difference", [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0], [1.0, 0.0, 1.0, 0.0]),
|
||||
("Exclusion", [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0], [1.0, 0.0, 1.0, 0.0]),
|
||||
("Multiply", [0.0, 0.0, 1.0, 0.0], [0.0, 0.5, 0.0, 0.0], [0.0, 0.5, 1.0, 0.0]),
|
||||
("Screen", [0.0, 1.0, 1.0, 0.0], [0.5, 0.5, 1.0, 0.0], [0.0, 0.5, 1.0, 0.0]),
|
||||
("Darken", [0.0, 0.0, 1.0, 0.0], [0.0, 0.5, 0.0, 0.0], [0.0, 0.5, 1.0, 0.0]),
|
||||
// Hue of (0,1,1) at the saturation and luminosity of (1,0.5,1): (0.355,0.855,0.855);
|
||||
// K is the backdrop's.
|
||||
("Hue", [0.0, 0.5, 0.0, 0.3], [1.0, 0.0, 0.0, 0.0], [0.645, 0.145, 0.145, 0.3]),
|
||||
// (1,0,1) at the luminosity of (0,1,0), clipped: (1,0.3051,1); K is the source's.
|
||||
("Luminosity", [0.0, 1.0, 0.0, 0.3], [1.0, 0.0, 1.0, 0.0], [0.0, 0.6949, 0.0, 0.0]),
|
||||
];
|
||||
let tint = |t: [f32; 4]| format!("{} {} {} {} k", t[0], t[1], t[2], t[3]);
|
||||
// `group`: the cell group's /Group entries; `b`: what the second element paints.
|
||||
let render = |group: &str, b: &dyn Fn([f32; 4]) -> String| {
|
||||
let (mut page, mut objects, mut names) = (String::new(), String::new(), String::new());
|
||||
for (i, (mode, ta, tb, expected)) in cells.iter().enumerate() {
|
||||
let x = 10 + i * 50;
|
||||
page += &format!("{} {x} 10 40 40 re f /G{i} Do\n", tint(*expected));
|
||||
let rect = format!("{} 20 20 20 re f", x + 10);
|
||||
let n = 10 + i * 3;
|
||||
for (k, paint) in [(n, tint(*ta)), (n + 1, b(*tb))] {
|
||||
let body = format!("{paint} {rect}");
|
||||
objects += &format!(
|
||||
"{k} 0 obj << /Type /XObject /Subtype /Form /BBox [{x} 10 {} 50] /Group << /S /Transparency /CS /DeviceCMYK >> /Length {} >> stream\n{body}\nendstream endobj\n",
|
||||
x + 40,
|
||||
body.len()
|
||||
);
|
||||
}
|
||||
let body = "/E1 Do /BM gs /E2 Do";
|
||||
objects += &format!(
|
||||
"{} 0 obj << /Type /XObject /Subtype /Form /BBox [{x} 10 {} 50] /Group << /S /Transparency {group} >> /Resources << /XObject << /E1 {n} 0 R /E2 {} 0 R >> /ExtGState << /BM << /BM /{mode} >> >> >> /Length {} >> stream\n{body}\nendstream endobj\n",
|
||||
n + 2,
|
||||
x + 40,
|
||||
n + 1,
|
||||
body.len()
|
||||
);
|
||||
names += &format!("/G{i} {} 0 R ", n + 2);
|
||||
}
|
||||
let pdf = format!(
|
||||
"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 370 60] /Group << /S /Transparency /CS /DeviceCMYK >> /Contents 4 0 R /Resources << /XObject << {names}>> >> >> endobj
|
||||
4 0 obj << /Length {} >> stream
|
||||
{page}
|
||||
endstream endobj
|
||||
{objects}trailer << /Root 1 0 R >>
|
||||
%%EOF",
|
||||
page.len()
|
||||
);
|
||||
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
|
||||
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
|
||||
assert!(p.error.is_none(), "{group}: {:?}", p.error);
|
||||
p
|
||||
};
|
||||
let px = |p: &RenderedPage, x: usize, y: u32| p.rgba[((y * p.width + x as u32) * 4) as usize..][..3].to_vec();
|
||||
let distance = |a: &[u8], b: &[u8]| a.iter().zip(b).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0);
|
||||
|
||||
let p = render("/CS /DeviceCMYK /I true", &tint);
|
||||
for (i, (mode, ..)) in cells.iter().enumerate() {
|
||||
let (group, square) = (px(&p, 30 + i * 50, 30), px(&p, 14 + i * 50, 30));
|
||||
// The square is a solid CMYK fill; the group lands within rounding of it.
|
||||
assert!(distance(&group, &square) <= 3, "{mode}: group {group:?}, square {square:?}");
|
||||
}
|
||||
|
||||
// A group whose contents have no ink tints (here the second element is RGB) is
|
||||
// composited in RGB exactly as before: the same as a group blending in DeviceRGB.
|
||||
let rgb = |_: [f32; 4]| "1 0 0 rg".to_string();
|
||||
let cmyk_group = render("/CS /DeviceCMYK /I true", &rgb);
|
||||
let rgb_group = render("/CS /DeviceRGB /I true", &rgb);
|
||||
assert_eq!(cmyk_group.rgba, rgb_group.rgba, "contents without tints are drawn as before");
|
||||
}
|
||||
|
||||
/// Two cases from the review of #3777. A group wider than about two of vello_cpu's tiles lost
|
||||
/// its blend mode in the tiles a paint covered completely, which drew black over magenta at
|
||||
/// 300 dpi where Difference gives green; each pass now draws inside a layer. And a nested
|
||||
/// isolated group that names another blending space (here DeviceRGB) blends in that space
|
||||
/// (ISO 32000-2 §11.3.4), so the enclosing CMYK group is drawn as before; a non-isolated
|
||||
/// one ignores its /CS and blends in the enclosing group's CMYK (Table 145).
|
||||
#[test]
|
||||
fn isolated_cmyk_groups_blend_at_any_size_and_respect_nested_spaces() {
|
||||
let render = |inner_cs: Option<&str>, scale: f32| {
|
||||
// `inner_cs`: the nested group's /CS and /I entries.
|
||||
let group = "0 1 0 0 k 0 0 200 200 re f /Diff gs 0 0 0 1 k 0 0 200 200 re f";
|
||||
let (outer_body, extra) = match inner_cs {
|
||||
None => (group.to_string(), String::new()),
|
||||
Some(cs) => (
|
||||
"/In Do".to_string(),
|
||||
format!(
|
||||
"6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency {cs} >> /Resources << /ExtGState << /Diff << /BM /Difference >> >> >> /Length {} >> stream\n{group}\nendstream endobj\n",
|
||||
group.len()
|
||||
),
|
||||
),
|
||||
};
|
||||
let page = "q 1 0 0 1 10 70 cm /X0 Do Q 1 0 1 0 k 10 10 200 50 re f";
|
||||
let pdf = format!(
|
||||
"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 220 280] /Contents 4 0 R /Resources << /XObject << /X0 5 0 R >> >> >> endobj
|
||||
4 0 obj << /Length {} >> stream
|
||||
{page}
|
||||
endstream endobj
|
||||
5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /CS /DeviceCMYK /I true >> /Resources << /XObject << /In 6 0 R >> /ExtGState << /Diff << /BM /Difference >> >> >> /Length {} >> stream
|
||||
{outer_body}
|
||||
endstream endobj
|
||||
{extra}trailer << /Root 1 0 R >>
|
||||
%%EOF",
|
||||
page.len(),
|
||||
outer_body.len()
|
||||
);
|
||||
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
|
||||
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale, tag: 0, ..Default::default() });
|
||||
assert!(p.error.is_none(), "{:?}", p.error);
|
||||
p
|
||||
};
|
||||
let px = |p: &RenderedPage, x: f32, y: f32, scale: f32| {
|
||||
let (x, y) = ((x * scale) as u32, (y * scale) as u32);
|
||||
p.rgba[((y * p.width + x) * 4) as usize..][..3].to_vec()
|
||||
};
|
||||
let distance = |a: &[u8], b: &[u8]| a.iter().zip(b).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0);
|
||||
|
||||
// 200 pt at scale 4 is 800 px: the centre lies in tiles the paints cover completely.
|
||||
for scale in [1.0, 4.0] {
|
||||
let p = render(None, scale);
|
||||
let strip = px(&p, 110.0, 245.0, scale);
|
||||
for (x, y) in [(110.0, 110.0), (15.0, 15.0), (110.0, 205.0)] {
|
||||
let group = px(&p, x, y, scale);
|
||||
assert!(distance(&group, &strip) <= 3, "scale {scale} at ({x}, {y}): group {group:?}, strip {strip:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// A nested DeviceRGB group: the whole CMYK group falls back, so the page is drawn exactly
|
||||
// as when the enclosing group is not offered to the CMYK path at all.
|
||||
let nested_rgb = render(Some("/CS /DeviceRGB /I true"), 1.0);
|
||||
let green = px(&nested_rgb, 110.0, 245.0, 1.0);
|
||||
assert!(distance(&px(&nested_rgb, 110.0, 110.0, 1.0), &green) > 100, "the nested group blends in RGB, not CMYK");
|
||||
// A nested DeviceCMYK group stays in the CMYK path.
|
||||
let nested_cmyk = render(Some("/CS /DeviceCMYK /I true"), 1.0);
|
||||
assert!(distance(&px(&nested_cmyk, 110.0, 110.0, 1.0), &green) <= 3);
|
||||
// A non-isolated nested group's /CS is ignored (ISO 32000-2 Table 145): it blends in the
|
||||
// enclosing group's CMYK, even when it names DeviceRGB.
|
||||
let non_isolated_rgb = render(Some("/CS /DeviceRGB /I false /K false"), 1.0);
|
||||
assert!(distance(&px(&non_isolated_rgb, 110.0, 110.0, 1.0), &green) <= 3, "a non-isolated nested group inherits CMYK");
|
||||
}
|
||||
|
||||
/// An element of a knockout group is first drawn in erase mode, which clears what the group
|
||||
/// drew under the element's shape. An isolated `DeviceCMYK` group nested in that element must
|
||||
/// clear too: when the erase pass drew it through the CMYK path, it painted over the red
|
||||
/// beneath instead of clearing it. The page must match the same element with its nested group
|
||||
/// blending in `DeviceRGB`, which never takes the CMYK path.
|
||||
#[test]
|
||||
fn an_isolated_cmyk_group_inside_a_knockout_element_is_erased_like_any_other() {
|
||||
let render = |inner_cs: &str| {
|
||||
let knockout = "1 0 0 rg 20 20 160 160 re f /El Do";
|
||||
let element = "/Cm Do";
|
||||
let inner = "/A gs 0 1 0 0 k 60 60 80 80 re f";
|
||||
let pdf = format!(
|
||||
"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /XObject << /Ko 5 0 R >> >> >> endobj
|
||||
4 0 obj << /Length 7 >> stream
|
||||
/Ko Do
|
||||
endstream endobj
|
||||
5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /I true /K true >> /Resources << /XObject << /El 6 0 R >> >> /Length {} >> stream
|
||||
{knockout}
|
||||
endstream endobj
|
||||
6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency >> /Resources << /XObject << /Cm 7 0 R >> >> /Length {} >> stream
|
||||
{element}
|
||||
endstream endobj
|
||||
7 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /CS {inner_cs} /I true >> /Resources << /ExtGState << /A << /ca 0.5 >> >> >> /Length {} >> stream
|
||||
{inner}
|
||||
endstream endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF",
|
||||
knockout.len(),
|
||||
element.len(),
|
||||
inner.len()
|
||||
);
|
||||
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
|
||||
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
|
||||
assert!(p.error.is_none(), "{inner_cs}: {:?}", p.error);
|
||||
// The centre of the inner square, (100, 100), is also the centre of the page.
|
||||
p.rgba[((100 * p.width + 100) * 4) as usize..][..3].to_vec()
|
||||
};
|
||||
let rgb = render("/DeviceRGB");
|
||||
let cmyk = render("/DeviceCMYK");
|
||||
assert!(rgb[2] > 150, "the RGB control shows half-opacity magenta over white, not red: {rgb:?}");
|
||||
assert!(rgb.iter().zip(&cmyk).all(|(a, b)| a.abs_diff(*b) <= 3), "CMYK {cmyk:?}, RGB {rgb:?}");
|
||||
}
|
||||
|
||||
/// A minimal page with `page` as its content, a shading pattern `/P` and an isolated
|
||||
/// `DeviceCMYK` group `/G` over `bbox` drawing `group`; the RGB of device pixel (`x`, `y`).
|
||||
fn cmyk_group_pixel(media: [u32; 2], page: &str, bbox: [u32; 4], group: &str, group_resources: &str, (x, y): (u32, u32)) -> Vec<u8> {
|
||||
let pdf = format!(
|
||||
"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 {} {}] /Contents 4 0 R /Resources << /XObject << /G 5 0 R >> /Pattern << /P 6 0 R >> >> >> endobj
|
||||
4 0 obj << /Length {} >> stream
|
||||
{page}
|
||||
endstream endobj
|
||||
5 0 obj << /Type /XObject /Subtype /Form /BBox [{} {} {} {}] /Group << /S /Transparency /CS /DeviceCMYK /I true >> /Resources << {group_resources} >> /Length {} >> stream
|
||||
{group}
|
||||
endstream endobj
|
||||
6 0 obj << /Type /Pattern /PatternType 2 /Matrix [2 0 0 2 30 40] /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 10 0] /Extend [true true] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> >> >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF",
|
||||
media[0],
|
||||
media[1],
|
||||
page.len(),
|
||||
bbox[0],
|
||||
bbox[1],
|
||||
bbox[2],
|
||||
bbox[3],
|
||||
group.len()
|
||||
);
|
||||
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
|
||||
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
|
||||
assert!(p.error.is_none(), "{:?}", p.error);
|
||||
p.rgba[((y * p.width + x) * 4) as usize..][..3].to_vec()
|
||||
}
|
||||
|
||||
/// From the review of #5228: a shading pattern painted before the group left its pattern
|
||||
/// transform on the context, and the finished group was drawn through it, so the cyan half
|
||||
/// of the group showed magenta. The group must look the same whether or not a pattern was
|
||||
/// painted first.
|
||||
#[test]
|
||||
fn a_pattern_painted_before_an_isolated_cmyk_group_does_not_move_it() {
|
||||
let group = "0 1 0 0 k 20 20 30 60 re f 1 0 0 0 k 50 20 30 60 re f";
|
||||
let render = |page: &str| cmyk_group_pixel([100, 100], page, [20, 20, 80, 80], group, "", (55, 50));
|
||||
let after_pattern = render("/Pattern cs /P scn 0 0 5 5 re f /G Do");
|
||||
let alone = render("/G Do");
|
||||
assert!(alone[2] > 150 && alone[0] < 100, "the control is cyan: {alone:?}");
|
||||
assert!(alone.iter().zip(&after_pattern).all(|(a, b)| a.abs_diff(*b) <= 3), "after a pattern {after_pattern:?}, alone {alone:?}");
|
||||
}
|
||||
|
||||
/// From the review of #5228: the Color blend mode over an isolated group's empty backdrop
|
||||
/// keeps the source colour (ISO 32000-2 §11.3.5.3 with nothing beneath). The K ink pass
|
||||
/// draws gray, and vello_cpu's nonseparable blend of a gray darkened some K levels to
|
||||
/// near black. Every 8-bit K level is checked against a plain fill of the same ink.
|
||||
#[test]
|
||||
fn the_color_blend_mode_over_an_empty_cmyk_group_keeps_every_k_level() {
|
||||
let mut wrong = Vec::new();
|
||||
for level in 0..=255u8 {
|
||||
let k = format!("0 0 0 {} k", f32::from(level) / 255.0);
|
||||
let page = format!("{k} 0 0 20 20 re f /G Do");
|
||||
let group = format!("/B gs {k} 20 0 20 20 re f");
|
||||
let plain = cmyk_group_pixel([40, 20], &page, [20, 0, 40, 20], &group, "/ExtGState << /B << /BM /Color >> >>", (10, 10));
|
||||
let blended = cmyk_group_pixel([40, 20], &page, [20, 0, 40, 20], &group, "/ExtGState << /B << /BM /Color >> >>", (30, 10));
|
||||
if plain.iter().zip(&blended).any(|(a, b)| a.abs_diff(*b) > 3) {
|
||||
wrong.push((level, plain, blended));
|
||||
}
|
||||
}
|
||||
assert!(wrong.is_empty(), "K levels drawn wrong (level, plain, blended): {wrong:?}");
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
//! Installed CJK faces for CID fonts a PDF doesn't embed.
|
||||
//!
|
||||
//! Acrobat and other tools often write Chinese, Japanese or Korean text with one of the Adobe
|
||||
//! collections (Adobe-Japan1, GB1, CNS1, Korea1) and a font name such as `KozGoPr6N-Medium` or
|
||||
//! `MSung-Light` without embedding the font. hayro maps those CIDs to Unicode itself, but it needs
|
||||
//! a face to draw them with; without one the text is blank. The craft-fonts Japanese faces come
|
||||
//! first (see `japanese_fallback` in `raster.rs`); this module is the last resort: one face
|
||||
//! already installed on this machine, used for the viewer only (`RenderConfig::system_fonts`, off
|
||||
//! for exports, printing and OCR). It is read at runtime and never embedded or shipped
|
||||
//! (AGENTS.md §1.4), Noto CJK and Source Han are left out on purpose (§1.1), and
|
||||
//! `PDFCRAFT_SYSTEM_FONTS=0` turns it off, as for the interface font.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Read as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
|
||||
|
||||
use hayro::hayro_interpret::font::{FallbackFontQuery, FontData};
|
||||
use hayro::hayro_interpret::hayro_cmap::CidFamily;
|
||||
use skrifa::MetadataProvider as _;
|
||||
use skrifa::string::StringId;
|
||||
|
||||
/// Larger files are not read: a font path is still untrusted input. CJK system faces are big
|
||||
/// collections (PingFang is tens of megabytes).
|
||||
const MAX_BYTES: u64 = 96 << 20;
|
||||
/// Faces tried in a collection (`.ttc`).
|
||||
const MAX_FACES: u32 = 32;
|
||||
/// Downloaded font assets looked at per kind on macOS.
|
||||
const MAX_ASSETS: usize = 512;
|
||||
|
||||
/// The writing system a character collection is for.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
enum Script {
|
||||
Japanese,
|
||||
SimplifiedChinese,
|
||||
TraditionalChinese,
|
||||
Korean,
|
||||
}
|
||||
|
||||
impl Script {
|
||||
/// Map the four supported Adobe CJK collections to their writing systems. Identity and
|
||||
/// custom collections provide no regional hint and cannot select an installed face here.
|
||||
fn of(family: &CidFamily) -> Option<Self> {
|
||||
match family {
|
||||
CidFamily::AdobeJapan1 => Some(Self::Japanese),
|
||||
CidFamily::AdobeGB1 => Some(Self::SimplifiedChinese),
|
||||
CidFamily::AdobeCNS1 => Some(Self::TraditionalChinese),
|
||||
CidFamily::AdobeKorea1 => Some(Self::Korean),
|
||||
CidFamily::AdobeIdentity | CidFamily::Custom { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A character the face must have to be worth using. Each is common and not shared by the
|
||||
/// other scripts' faces: 物 (Japanese; also Chinese, so it only rules out Latin-only faces),
|
||||
/// 欢 (Simplified only), 說 (Traditional only), 한 (Hangul).
|
||||
fn probe(self) -> char {
|
||||
match self {
|
||||
Self::Japanese => '\u{7269}',
|
||||
Self::SimplifiedChinese => '\u{6B22}',
|
||||
Self::TraditionalChinese => '\u{8AAA}',
|
||||
Self::Korean => '\u{D55C}',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One installed file and the words (in a face's family name) that pick the face for the right
|
||||
/// region out of a collection that holds several (PingFang SC, TC and HK share one file).
|
||||
struct Candidate {
|
||||
path: PathBuf,
|
||||
region: &'static [&'static str],
|
||||
}
|
||||
|
||||
/// Keep named Windows body, heading, calligraphic and FangSong faces distinct. A cache keyed only
|
||||
/// by script and weight would make the first Chinese font on a page stand in for all of them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
enum ChineseFamily {
|
||||
Default,
|
||||
Song,
|
||||
Hei,
|
||||
Kai,
|
||||
FangSong,
|
||||
}
|
||||
|
||||
impl ChineseFamily {
|
||||
/// The family a font name asks for: the Windows and Founder (方正) names that Chinese PDFs,
|
||||
/// CNKI papers in particular, use without embedding the font. FangSong is tested first, as
|
||||
/// its names contain "song" too. Founder PostScript names are matched by prefix (`FZSSK--GBK1-0`
|
||||
/// is 方正书宋, `FZXBSK--GBK1-0` 方正小标宋, `FZHTK--GBK1-0` 方正黑体, `FZKTK--GBK1-0` 方正楷体,
|
||||
/// `FZFSK--GBK1-0` 方正仿宋).
|
||||
fn of(name: &str) -> Self {
|
||||
// PDF subset names prepend six uppercase ASCII letters and '+'. Match the actual
|
||||
// PostScript name so Founder prefixes work for subsets too.
|
||||
let name = match name.split_once('+') {
|
||||
Some((tag, family)) if tag.len() == 6 && tag.bytes().all(|b| b.is_ascii_uppercase()) => family,
|
||||
_ => name,
|
||||
};
|
||||
let name = name.to_ascii_lowercase();
|
||||
let has = |hints: &[&str]| hints.iter().any(|hint| name.contains(hint));
|
||||
let starts = |prefixes: &[&str]| prefixes.iter().any(|p| name.starts_with(p));
|
||||
if has(&["fangsong", "simfang", "仿宋"]) || starts(&["fzfs"]) {
|
||||
Self::FangSong
|
||||
} else if has(&["simsun", "stsong", "songti", "song", "宋"]) || starts(&["fzss", "fzbs", "fzxbs", "fzcs"]) {
|
||||
Self::Song
|
||||
} else if has(&["simhei", "stheiti", "stxihei", "heiti", "黑"]) || starts(&["fzht", "fzhei", "fzlth", "fzdh"]) {
|
||||
Self::Hei
|
||||
} else if has(&["kaiti", "stkaiti", "kai", "楷"]) || starts(&["fzkt", "fzkai"]) {
|
||||
Self::Kai
|
||||
} else {
|
||||
Self::Default
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The installed face to draw a CID font of `query`'s collection with, as `(data, face index)`.
|
||||
/// `None` when it is turned off, the collection isn't one of the four, or nothing fits.
|
||||
pub(crate) fn fallback(query: &FallbackFontQuery) -> Option<(FontData, u32)> {
|
||||
if cfg!(target_arch = "wasm32") || std::env::var_os("PDFCRAFT_SYSTEM_FONTS").is_some_and(|v| v == "0") {
|
||||
return None;
|
||||
}
|
||||
let script = Script::of(&query.character_collection.as_ref()?.family)?;
|
||||
let bold = query.is_bold || query.font_weight >= 600;
|
||||
let family = ChineseFamily::of(query.post_script_name.as_deref().unwrap_or_default());
|
||||
/// A face file and the index of the chosen face in it, or `None` when nothing fits.
|
||||
type Face = Option<(Arc<Vec<u8>>, u32)>;
|
||||
type Faces = HashMap<(Script, bool, ChineseFamily), Face>;
|
||||
static CACHE: Mutex<Option<Faces>> = Mutex::new(None);
|
||||
let mut cache = CACHE.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let found = cache.get_or_insert_with(HashMap::new).entry((script, bold, family)).or_insert_with(|| {
|
||||
candidates(script, family, bold).iter().find_map(|c| {
|
||||
read(c, script.probe(), bold).inspect(|(_, index)| {
|
||||
log::info!("CJK font fallback for {script:?}/{family:?}: {} (face {index})", c.path.display());
|
||||
})
|
||||
})
|
||||
});
|
||||
found.as_ref().map(|(bytes, index)| (Arc::clone(bytes) as FontData, *index))
|
||||
}
|
||||
|
||||
/// Read a candidate through the bounded file cache and select a face that maps `probe`,
|
||||
/// preferring the requested region and weight. Unreadable files and unsuitable faces return
|
||||
/// `None`, allowing the caller to try the next candidate without changing the PDF.
|
||||
fn read(candidate: &Candidate, probe: char, bold: bool) -> Option<(Arc<Vec<u8>>, u32)> {
|
||||
let bytes = file_bytes(&candidate.path)?;
|
||||
let index = pick_face(&bytes, probe, candidate.region, bold)?;
|
||||
Some((bytes, index))
|
||||
}
|
||||
|
||||
/// The bytes of an installed font file, read once for the whole process and shared: a collection
|
||||
/// such as `PingFang.ttc` is tens of megabytes and is the candidate of several scripts and both
|
||||
/// weights, so the per-script cache keeps only a handle and a face index. A file that cannot be
|
||||
/// used is remembered too, so it is not tried again for every script or family.
|
||||
fn file_bytes(path: &Path) -> Option<Arc<Vec<u8>>> {
|
||||
/// The bytes of each file tried, or `None` for one that cannot be used.
|
||||
type Files = HashMap<PathBuf, Option<Arc<Vec<u8>>>>;
|
||||
static FILES: Mutex<Option<Files>> = Mutex::new(None);
|
||||
let mut files = FILES.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
files.get_or_insert_with(HashMap::new).entry(path.to_path_buf()).or_insert_with(|| read_file(path, MAX_BYTES).map(Arc::new)).clone()
|
||||
}
|
||||
|
||||
/// A regular file of at most `max` bytes, or `None`: a font path is untrusted input.
|
||||
fn read_file(path: &Path, max: u64) -> Option<Vec<u8>> {
|
||||
// Reject special files before opening them (in particular a FIFO can block on open).
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
if !meta.is_file() || meta.len() > max {
|
||||
return None;
|
||||
}
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
let meta = file.metadata().ok()?;
|
||||
if !meta.is_file() || meta.len() > max {
|
||||
return None;
|
||||
}
|
||||
// Bound the actual read too: the file can grow after the metadata check.
|
||||
let mut bytes = Vec::new();
|
||||
file.take(max.saturating_add(1)).read_to_end(&mut bytes).ok()?;
|
||||
(bytes.len() as u64 <= max).then_some(bytes)
|
||||
}
|
||||
|
||||
/// The best face of the file that parses and maps `probe`: the region's face when the file has
|
||||
/// one, then the regular (or, for bold text, the semibold or bold) weight, then the first.
|
||||
fn pick_face(bytes: &[u8], probe: char, region: &[&str], bold: bool) -> Option<u32> {
|
||||
let mut best: Option<(u32, u32)> = None;
|
||||
for index in 0..MAX_FACES {
|
||||
let Ok(font) = skrifa::FontRef::from_index(bytes, index) else { continue };
|
||||
if font.charmap().map(probe).is_none() {
|
||||
continue;
|
||||
}
|
||||
let names = |id: StringId| font.localized_strings(id).map(|s| s.chars().collect::<String>()).collect::<Vec<_>>();
|
||||
let family = names(StringId::FAMILY_NAME).join(" ");
|
||||
let style = names(StringId::SUBFAMILY_NAME).join(" ").to_ascii_lowercase();
|
||||
let mut score = 1;
|
||||
if region.iter().any(|r| family.split_whitespace().any(|w| w == *r)) {
|
||||
score += 4;
|
||||
}
|
||||
let weighty = style.contains("bold") || style.contains("semibold") || style.contains("heavy");
|
||||
let plain = style.is_empty() || style.contains("regular") || style.contains("normal") || style.contains("medium") && !bold;
|
||||
if (bold && weighty) || (!bold && plain && !weighty) {
|
||||
score += 2;
|
||||
}
|
||||
if best.is_none_or(|(_, s)| score > s) {
|
||||
best = Some((index, score));
|
||||
}
|
||||
}
|
||||
best.map(|(index, _)| index)
|
||||
}
|
||||
|
||||
/// List installed-font paths in preference order, with regional face-name hints for collections.
|
||||
/// Windows Simplified Chinese first tries the named document family; its generic list also
|
||||
/// prefers a bold face for bold requests. macOS includes bounded downloaded-font locations.
|
||||
/// Only fixed platform filenames are used: a PDF font name never becomes a filesystem path.
|
||||
fn candidates(script: Script, family: ChineseFamily, bold: bool) -> Vec<Candidate> {
|
||||
let region: &'static [&'static str] = match script {
|
||||
Script::Japanese => &["JP", "Japanese"],
|
||||
Script::SimplifiedChinese => &["SC", "GB"],
|
||||
Script::TraditionalChinese => &["TC", "HK", "CNS"],
|
||||
Script::Korean => &["KR", "Korean"],
|
||||
};
|
||||
let files: Vec<PathBuf> = if cfg!(windows) {
|
||||
let dir = windows_fonts();
|
||||
let names: &[&str] = match script {
|
||||
Script::Japanese => &["YuGothR.ttc", "meiryo.ttc", "msgothic.ttc"],
|
||||
Script::SimplifiedChinese => match family {
|
||||
ChineseFamily::Song => &["simsun.ttc", "msyh.ttc", "msjh.ttc"],
|
||||
ChineseFamily::Hei => &["simhei.ttf", "msyh.ttc", "simsun.ttc", "msjh.ttc"],
|
||||
ChineseFamily::Kai => &["simkai.ttf", "simsun.ttc", "msyh.ttc", "msjh.ttc"],
|
||||
ChineseFamily::FangSong => &["simfang.ttf", "simsun.ttc", "msyh.ttc", "msjh.ttc"],
|
||||
ChineseFamily::Default if bold => &["msyhbd.ttc", "msyh.ttc", "simsun.ttc", "msjh.ttc"],
|
||||
ChineseFamily::Default => &["msyh.ttc", "simsun.ttc", "msjh.ttc"],
|
||||
},
|
||||
Script::TraditionalChinese => &["msjh.ttc", "mingliu.ttc", "msyh.ttc"],
|
||||
Script::Korean => &["malgun.ttf", "gulim.ttc"],
|
||||
};
|
||||
names.iter().map(|f| dir.join(f)).collect()
|
||||
} else if cfg!(target_os = "macos") {
|
||||
let names: &[&str] = match script {
|
||||
Script::Japanese => &["ヒラギノ角ゴシック W3.ttc", "Hiragino Sans GB.ttc", "PingFang.ttc"],
|
||||
Script::SimplifiedChinese => &["PingFang.ttc", "Hiragino Sans GB.ttc", "STHeiti Light.ttc"],
|
||||
Script::TraditionalChinese => &["PingFang.ttc", "STHeiti Light.ttc", "Hiragino Sans GB.ttc"],
|
||||
Script::Korean => &["AppleSDGothicNeo.ttc", "AppleGothic.ttf", "PingFang.ttc"],
|
||||
};
|
||||
// The system folder first, then the downloaded font assets: on recent macOS a face such as
|
||||
// PingFang lives in `/System/Library/AssetsV2/com_apple_MobileAsset_Font*/<id>.asset/AssetData`.
|
||||
let mut dirs = vec![PathBuf::from("/System/Library/Fonts")];
|
||||
dirs.extend(macos_font_asset_dirs().iter().cloned());
|
||||
names.iter().flat_map(|f| dirs.iter().map(move |d| d.join(f))).collect()
|
||||
} else {
|
||||
let names: &[&str] = match script {
|
||||
Script::Korean => {
|
||||
&["truetype/nanum/NanumGothic.ttf", "nanum/NanumGothic.ttf", "truetype/baekmuk/gulim.ttf", "truetype/unfonts-core/UnDotum.ttf"]
|
||||
}
|
||||
Script::Japanese => &[
|
||||
"truetype/fonts-japanese-gothic.ttf",
|
||||
"ipa-gothic/ipag.ttf",
|
||||
"truetype/takao-gothic/TakaoGothic.ttf",
|
||||
"truetype/vlgothic/VL-Gothic-Regular.ttf",
|
||||
],
|
||||
Script::SimplifiedChinese | Script::TraditionalChinese => &[
|
||||
"truetype/wqy/wqy-microhei.ttc",
|
||||
"wenquanyi/wqy-microhei/wqy-microhei.ttc",
|
||||
"wqy-microhei/wqy-microhei.ttc",
|
||||
"truetype/wqy/wqy-zenhei.ttc",
|
||||
"wenquanyi/wqy-zenhei/wqy-zenhei.ttc",
|
||||
"wqy-zenhei/wqy-zenhei.ttc",
|
||||
"truetype/droid/DroidSansFallbackFull.ttf",
|
||||
"google-droid-sans-fonts/DroidSansFallbackFull.ttf",
|
||||
"truetype/arphic/uming.ttc",
|
||||
"arphic-uming/uming.ttc",
|
||||
],
|
||||
};
|
||||
["/usr/share/fonts", "/usr/local/share/fonts"].iter().flat_map(|dir| names.iter().map(move |f| Path::new(dir).join(f))).collect()
|
||||
};
|
||||
files.into_iter().map(|path| Candidate { path, region }).collect()
|
||||
}
|
||||
|
||||
/// The `AssetData` folders of the downloaded font assets of macOS, found once. The ids in their
|
||||
/// names change with each asset version, so they are listed rather than named; at most
|
||||
/// [`MAX_ASSETS`] are looked at.
|
||||
fn macos_font_asset_dirs() -> &'static [PathBuf] {
|
||||
static DIRS: OnceLock<Vec<PathBuf>> = OnceLock::new();
|
||||
DIRS.get_or_init(|| {
|
||||
let root = Path::new("/System/Library/AssetsV2");
|
||||
let Ok(kinds) = std::fs::read_dir(root) else { return Vec::new() };
|
||||
let mut dirs = Vec::new();
|
||||
for kind in kinds.flatten().filter(|k| k.file_name().to_string_lossy().starts_with("com_apple_MobileAsset_Font")) {
|
||||
let Ok(assets) = std::fs::read_dir(kind.path()) else { continue };
|
||||
for asset in assets.flatten().take(MAX_ASSETS) {
|
||||
let data = asset.path().join("AssetData");
|
||||
if data.is_dir() {
|
||||
dirs.push(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
dirs
|
||||
})
|
||||
}
|
||||
|
||||
/// Locate the Windows Fonts directory using `WINDIR`, then `SystemRoot`, then `C:\Windows`.
|
||||
/// This only constructs the path; candidate reads still enforce regular-file and size limits.
|
||||
fn windows_fonts() -> PathBuf {
|
||||
let dir = std::env::var_os("WINDIR").or_else(|| std::env::var_os("SystemRoot")).map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from);
|
||||
dir.join("Fonts")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SCRIPTS: [Script; 4] = [Script::Japanese, Script::SimplifiedChinese, Script::TraditionalChinese, Script::Korean];
|
||||
|
||||
/// Standard, localized and subset-prefixed names retain their Chinese family hint;
|
||||
/// unrelated Latin names and generic YaHei requests use the default candidate list.
|
||||
#[test]
|
||||
fn chinese_family_hints_include_subset_and_localized_names() {
|
||||
for name in ["SimSun", "ABCDEF+SimSun", "STSong-Light", "宋体"] {
|
||||
assert_eq!(ChineseFamily::of(name), ChineseFamily::Song, "{name}");
|
||||
}
|
||||
for name in ["SimHei", "STHeiti", "黑体"] {
|
||||
assert_eq!(ChineseFamily::of(name), ChineseFamily::Hei, "{name}");
|
||||
}
|
||||
for name in ["KaiTi", "STKaiti", "楷体"] {
|
||||
assert_eq!(ChineseFamily::of(name), ChineseFamily::Kai, "{name}");
|
||||
}
|
||||
assert_eq!(ChineseFamily::of("Helvetica"), ChineseFamily::Default);
|
||||
assert_eq!(ChineseFamily::of("MicrosoftYaHei"), ChineseFamily::Default);
|
||||
}
|
||||
|
||||
/// FangSong names contain "song" and must not become Song; Founder (方正) names in CNKI papers
|
||||
/// pick the matching installed family instead of YaHei for everything (#3587).
|
||||
#[test]
|
||||
fn fangsong_and_founder_names_pick_their_family() {
|
||||
for name in ["FangSong", "FangSong_GB2312", "STFangsong", "SimFang", "仿宋", "FZFSK--GBK1-0", "ABCDEF+FZFSK--GBK1-0"] {
|
||||
assert_eq!(ChineseFamily::of(name), ChineseFamily::FangSong, "{name}");
|
||||
}
|
||||
for name in ["FZSSK--GBK1-0", "FZBSK--GBK1-0", "FZXBSK--GBK1-0", "FZShuSong-Z01", "STZhongsong", "NSimSun", "ABCDEF+FZSSK--GBK1-0"] {
|
||||
assert_eq!(ChineseFamily::of(name), ChineseFamily::Song, "{name}");
|
||||
}
|
||||
for name in ["FZHTK--GBK1-0", "FZLTHJW--GB1-0", "STXihei", "ABCDEF+FZHTK--GBK1-0"] {
|
||||
assert_eq!(ChineseFamily::of(name), ChineseFamily::Hei, "{name}");
|
||||
}
|
||||
for name in ["FZKTK--GBK1-0", "KaiTi_GB2312", "ABCDEF+FZKTK--GBK1-0"] {
|
||||
assert_eq!(ChineseFamily::of(name), ChineseFamily::Kai, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every family has a Windows list that ends in faces any Chinese Windows has, so a missing
|
||||
/// named face still draws the text.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn every_windows_chinese_family_falls_back_to_common_faces() {
|
||||
for family in [ChineseFamily::Default, ChineseFamily::Song, ChineseFamily::Hei, ChineseFamily::Kai, ChineseFamily::FangSong] {
|
||||
let list = candidates(Script::SimplifiedChinese, family, false);
|
||||
assert!(list.iter().any(|c| c.path.ends_with("msyh.ttc")), "{family:?}");
|
||||
}
|
||||
let fangsong = candidates(Script::SimplifiedChinese, ChineseFamily::FangSong, false);
|
||||
assert!(fangsong.first().is_some_and(|c| c.path.ends_with("simfang.ttf")));
|
||||
}
|
||||
|
||||
/// Invalid font bytes, missing paths and directories produce no candidate face rather
|
||||
/// than preventing the resolver from trying a later installed font.
|
||||
#[test]
|
||||
fn broken_and_missing_files_are_skipped() {
|
||||
assert_eq!(pick_face(b"", '\u{7269}', &[], false), None);
|
||||
assert_eq!(pick_face(b"not a font at all", '\u{7269}', &[], false), None);
|
||||
assert_eq!(pick_face(&[0u8; 4096], '\u{7269}', &[], false), None);
|
||||
let missing = Candidate { path: PathBuf::from("definitely/not/here.ttc"), region: &[] };
|
||||
assert!(read(&missing, '\u{7269}', false).is_none());
|
||||
// A directory is not a font.
|
||||
let dir = Candidate { path: std::env::temp_dir(), region: &[] };
|
||||
assert!(read(&dir, '\u{7269}', false).is_none());
|
||||
}
|
||||
|
||||
/// The files are bounded by `max`: a larger one is not read.
|
||||
#[test]
|
||||
fn a_file_over_the_cap_is_not_read() {
|
||||
let inter = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/Inter-Regular.ttf"));
|
||||
assert!(read_file(inter, MAX_BYTES).is_some());
|
||||
assert!(read_file(inter, 1024).is_none());
|
||||
}
|
||||
|
||||
/// Two faces drawn from one installed file (two scripts, or regular and bold) hold the same
|
||||
/// bytes, not a copy each; a file that can't be used is remembered as unusable.
|
||||
#[test]
|
||||
fn one_installed_file_is_read_once_and_shared() {
|
||||
let inter = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/Inter-Regular.ttf"));
|
||||
let first = file_bytes(inter).expect("readable");
|
||||
let second = file_bytes(inter).expect("readable");
|
||||
assert!(Arc::ptr_eq(&first, &second), "one allocation for the file");
|
||||
let missing = Path::new("definitely/not/here.ttc");
|
||||
assert!(file_bytes(missing).is_none() && file_bytes(missing).is_none());
|
||||
}
|
||||
|
||||
/// A Latin-only face fails every CJK probe even though its Latin glyph remains readable,
|
||||
/// distinguishing unsupported script coverage from an unparseable font file.
|
||||
#[test]
|
||||
fn a_latin_face_is_rejected_for_every_script() {
|
||||
let inter = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
|
||||
for script in SCRIPTS {
|
||||
assert_eq!(pick_face(inter, script.probe(), &[], false), None, "{script:?}");
|
||||
}
|
||||
assert_eq!(pick_face(inter, 'A', &[], false), Some(0));
|
||||
}
|
||||
|
||||
/// Platform candidates are absolute TrueType paths and exclude the font families barred
|
||||
/// by the asset policy, independently of whether those files are installed locally.
|
||||
#[test]
|
||||
fn candidates_are_absolute_font_files_without_adobe_designs() {
|
||||
for script in SCRIPTS {
|
||||
let list = candidates(script, ChineseFamily::Default, false);
|
||||
assert!(!list.is_empty(), "{script:?}");
|
||||
for c in list {
|
||||
assert!(c.path.is_absolute(), "{}", c.path.display());
|
||||
assert!(c.path.extension().is_some_and(|e| e == "ttf" || e == "ttc"), "{}", c.path.display());
|
||||
let name = c.path.to_string_lossy().to_lowercase();
|
||||
assert!(
|
||||
!["notosanscjk", "notoserifcjk", "sourcehan", "source-han", "adobe", "kozuka", "minion"].iter().any(|b| name.contains(b)),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The four regional Adobe collections select their corresponding scripts; Identity
|
||||
/// carries no regional information and must not acquire an installed fallback by guess.
|
||||
#[test]
|
||||
fn only_the_four_adobe_collections_have_a_script() {
|
||||
assert_eq!(Script::of(&CidFamily::AdobeCNS1), Some(Script::TraditionalChinese));
|
||||
assert_eq!(Script::of(&CidFamily::AdobeGB1), Some(Script::SimplifiedChinese));
|
||||
assert_eq!(Script::of(&CidFamily::AdobeKorea1), Some(Script::Korean));
|
||||
assert_eq!(Script::of(&CidFamily::AdobeJapan1), Some(Script::Japanese));
|
||||
assert_eq!(Script::of(&CidFamily::AdobeIdentity), None);
|
||||
}
|
||||
|
||||
/// A real Windows GBK book names these faces. Replacing both with YaHei hides the
|
||||
/// distinction between serif body text and sans headings even when the exact face exists.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_preserves_installed_chinese_document_families() {
|
||||
use hayro::hayro_interpret::hayro_cmap::CharacterCollection;
|
||||
for (name, expected, file) in [
|
||||
("SimSun", "SimSun", "simsun.ttc"),
|
||||
("SimHei", "SimHei", "simhei.ttf"),
|
||||
("KaiTi", "KaiTi", "simkai.ttf"),
|
||||
("FangSong", "FangSong", "simfang.ttf"),
|
||||
("ABCDEF+FZSSK--GBK1-0", "SimSun", "simsun.ttc"),
|
||||
("ABCDEF+FZHTK--GBK1-0", "SimHei", "simhei.ttf"),
|
||||
("ABCDEF+FZKTK--GBK1-0", "KaiTi", "simkai.ttf"),
|
||||
("ABCDEF+FZFSK--GBK1-0", "FangSong", "simfang.ttf"),
|
||||
] {
|
||||
if !windows_fonts().join(file).is_file() {
|
||||
continue;
|
||||
}
|
||||
let query = FallbackFontQuery {
|
||||
post_script_name: Some(name.into()),
|
||||
character_collection: Some(CharacterCollection { family: CidFamily::AdobeGB1, supplement: 4 }),
|
||||
..FallbackFontQuery::default()
|
||||
};
|
||||
let (bytes, index) = fallback(&query).expect("installed Chinese font");
|
||||
let font = skrifa::FontRef::from_index(bytes.as_ref().as_ref(), index).unwrap();
|
||||
let names: Vec<String> = font.localized_strings(StringId::POSTSCRIPT_NAME).map(|n| n.chars().collect()).collect();
|
||||
assert!(names.iter().any(|n| n.eq_ignore_ascii_case(expected)), "{name} resolved to {names:?}, expected {expected}");
|
||||
}
|
||||
}
|
||||
|
||||
/// On a Mac the face for Traditional Chinese is the TC one of PingFang, not the first of the
|
||||
/// collection (which is another region's), and it maps a character only Traditional has.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn macos_picks_the_regional_pingfang_face() {
|
||||
// Found where this macOS keeps it: the system folder, or the downloaded font assets.
|
||||
let path = candidates(Script::TraditionalChinese, ChineseFamily::Default, false)
|
||||
.into_iter()
|
||||
.map(|c| c.path)
|
||||
.find(|p| p.file_name().is_some_and(|n| n == "PingFang.ttc") && p.is_file());
|
||||
let Some(bytes) = path.and_then(|p| std::fs::read(p).ok()) else {
|
||||
eprintln!("PingFang is not installed here: skipped");
|
||||
return;
|
||||
};
|
||||
for (script, region) in [(Script::TraditionalChinese, "TC"), (Script::SimplifiedChinese, "SC")] {
|
||||
let index = pick_face(&bytes, script.probe(), &[region], false).expect("PingFang covers it");
|
||||
let font = skrifa::FontRef::from_index(&bytes, index).unwrap();
|
||||
let family: String = font.localized_strings(StringId::FAMILY_NAME).next().unwrap().chars().collect();
|
||||
assert!(family.contains(region), "{script:?} got {family:?}");
|
||||
}
|
||||
}
|
||||
/// A face that this macOS keeps only in its downloaded font assets (PingFang, on recent
|
||||
/// versions) is among the candidates. The assets are searched here on their own, not by the
|
||||
/// code under test; where there are none, nothing is checked.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn macos_candidates_include_a_pingfang_that_lives_in_the_font_assets() {
|
||||
/// Independently locate an installed asset by filename within the test's depth limit,
|
||||
/// providing an oracle for the production candidate list without using its discovery.
|
||||
fn find(dir: &Path, name: &str, depth: u32) -> Option<PathBuf> {
|
||||
for entry in std::fs::read_dir(dir).ok()?.flatten() {
|
||||
let path = entry.path();
|
||||
if path.file_name().is_some_and(|n| n == name) && path.is_file() {
|
||||
return Some(path);
|
||||
}
|
||||
if depth > 0
|
||||
&& path.is_dir()
|
||||
&& !path.is_symlink()
|
||||
&& let Some(found) = find(&path, name, depth - 1)
|
||||
{
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
let Some(asset) = find(Path::new("/System/Library/AssetsV2"), "PingFang.ttc", 3) else {
|
||||
eprintln!("no PingFang in the font assets here: skipped");
|
||||
return;
|
||||
};
|
||||
for script in [Script::SimplifiedChinese, Script::TraditionalChinese] {
|
||||
assert!(
|
||||
candidates(script, ChineseFamily::Default, false).iter().any(|c| c.path == asset),
|
||||
"{script:?}: {} is not a candidate",
|
||||
asset.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -385,7 +385,7 @@ pub use layout::layout;
|
||||
use layout::{from_upright, to_upright};
|
||||
use layout::{is_rtl, union};
|
||||
#[cfg(test)]
|
||||
use order::{Run, Scratch, collide, restarts_run};
|
||||
use order::{Run, Scratch, Seg, collide, restarts_run};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::layout::{Flow, along_baseline, arrange_flow, compatible, continues, from_upright, to_upright, unrolled};
|
||||
use super::*;
|
||||
use super::{
|
||||
Affine, BezPath, BlendMode, ClipPath, Context, Device, Glyph, GlyphDrawMode, HashMap, Image, InterpreterCache, InterpreterSettings, PageText,
|
||||
Paint, PathDrawMode, Pdf, Rect, Shape, SoftMask, TextFilter, TextGlyph, TransformExt, interpret_page,
|
||||
};
|
||||
|
||||
/// Largest sine of the angle between a glyph's writing direction and a flow's for the glyph to be
|
||||
/// laid out in that flow's frame. Turning an em box by θ moves its edges along the line by up to
|
||||
@@ -24,9 +27,12 @@ pub(super) struct TextDevice {
|
||||
actual: Option<(usize, String, Vec<Drawn>)>,
|
||||
/// Every `/ActualText` replacement made on the page.
|
||||
pub(super) replaced: Vec<Replaced>,
|
||||
/// Visible page bounds in view space (`intersected_crop_box` after `initial_transform`).
|
||||
page: Rect,
|
||||
filter: TextFilter,
|
||||
excluded_depth: Option<usize>,
|
||||
actual_filtered: bool,
|
||||
actual_cropped: bool,
|
||||
partial_replacement: bool,
|
||||
}
|
||||
|
||||
@@ -106,9 +112,11 @@ impl TextDevice {
|
||||
depth: 0,
|
||||
actual: None,
|
||||
replaced: Vec::new(),
|
||||
page: Rect::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::INFINITY),
|
||||
filter: TextFilter::default(),
|
||||
excluded_depth: None,
|
||||
actual_filtered: false,
|
||||
actual_cropped: false,
|
||||
partial_replacement: false,
|
||||
}
|
||||
}
|
||||
@@ -123,12 +131,17 @@ impl TextDevice {
|
||||
/// Unicode). An empty one (a soft hyphen at a line break, say) stands for no text at all.
|
||||
fn end_actual_text(&mut self) {
|
||||
let Some((_, text, drawn)) = self.actual.take() else { return };
|
||||
let cropped = std::mem::take(&mut self.actual_cropped);
|
||||
if std::mem::take(&mut self.actual_filtered) {
|
||||
// ActualText has no mapping from replacement words to individual glyphs. A mixed
|
||||
// span cannot be filtered honestly; a wholly excluded span can simply be omitted.
|
||||
self.partial_replacement |= !drawn.is_empty();
|
||||
return;
|
||||
}
|
||||
if cropped {
|
||||
self.glyphs.extend(drawn.into_iter().filter(|d| !d.2.text.chars().all(|c| c.is_control())));
|
||||
return;
|
||||
}
|
||||
let Some(glyph) = merge(&drawn, text.clone()) else {
|
||||
self.glyphs.extend(drawn.into_iter().filter(|d| !d.2.text.chars().all(char::is_control)));
|
||||
return;
|
||||
@@ -313,7 +326,13 @@ impl<'a> Device<'a> for TextDevice {
|
||||
let b = corners.iter().fold(Rect::new(f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY), |b, p| {
|
||||
Rect::new(b.x0.min(p.x), b.y0.min(p.y), b.x1.max(p.x), b.y1.max(p.y))
|
||||
});
|
||||
if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()) || b.width() > 10_000.0 || b.height() > 10_000.0 {
|
||||
let outside_crop = b.x0.max(self.page.x0) >= b.x1.min(self.page.x1) || b.y0.max(self.page.y0) >= b.y1.min(self.page.y1);
|
||||
if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite())
|
||||
|| b.width() > 10_000.0
|
||||
|| b.height() > 10_000.0
|
||||
|| outside_crop
|
||||
{
|
||||
self.actual_cropped |= replaced && outside_crop;
|
||||
return;
|
||||
}
|
||||
let length = dx.hypot(dy);
|
||||
@@ -371,8 +390,10 @@ pub(crate) fn extract_page_filtered(pdf: &Pdf, page: usize, settings: &Interpret
|
||||
// as in Acrobat, and match what other extractors report.
|
||||
let settings = settings.clone();
|
||||
let initial = p.initial_transform(true).to_kurbo();
|
||||
let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, w as f64, h as f64), &cache, p.xref(), settings);
|
||||
let c = p.intersected_crop_box();
|
||||
let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, f64::from(w), f64::from(h)), &cache, p.xref(), settings);
|
||||
let mut dev = TextDevice::new();
|
||||
dev.page = initial.transform_rect_bbox(Rect::new(c.x0, c.y0, c.x1, c.y1));
|
||||
dev.filter = filter;
|
||||
interpret_page(p, &mut ctx, &mut dev);
|
||||
// A replacement the content never closed ends with the page.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::device::{DIRECTION_TOLERANCE, Drawn, WORD_GAP};
|
||||
use super::order::{NONE, REDRAW_CANDIDATES, Run, Scratch, Seg, fingerprint, place_glyphs, redraws, restarts_run, reverse_rtl};
|
||||
use super::*;
|
||||
use super::order::{NONE, REDRAW_CANDIDATES, Run, Scratch, Seg, fingerprint, is_script_on, place_glyphs, redraws, restarts_run, reverse_rtl};
|
||||
use super::{HashMap, PageText, TextGlyph};
|
||||
|
||||
/// Glyphs in reading order, as indices into the page's glyphs, with their lines and word gaps.
|
||||
pub(super) struct Flow {
|
||||
@@ -177,14 +177,19 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec<usize>, Vec<u32>, Vec<bool>) {
|
||||
let mut furthest_progress = 0.0;
|
||||
for i in 0..n {
|
||||
let g = glyphs[i].rect;
|
||||
let hi = height(i);
|
||||
let cont = segs.last().is_some_and(|s| {
|
||||
let Some(p) = s.first.end.checked_sub(1) else { return false };
|
||||
let ph = height(p).min(height(i));
|
||||
let ph = height(p).min(hi);
|
||||
let sh = s.h.min(hi);
|
||||
let gap = g[0] - glyphs[p].rect[2];
|
||||
// A sub- or superscript is a smaller glyph on a shifted baseline: its em box centre sits off the line by
|
||||
// more than half its own height, but by less than half the height of the full-size glyph beside it.
|
||||
let centre_limit = if gap.abs() < ph { height(p).max(height(i)) * 0.5 } else { ph * 0.5 };
|
||||
(cy(i) - cy(p)).abs() < centre_limit && gap < ph * 3.0 && g[0] > glyphs[p].rect[0] - ph * 2.0
|
||||
let centre_limit = if gap.abs() < ph { height(p).max(hi) * 0.5 } else { ph * 0.5 };
|
||||
let same_line = ((cy(i) - cy(p)).abs() < centre_limit && gap < ph * 3.0)
|
||||
|| ((cy(i) - s.cy()).abs() < sh * 0.3 && (s.h / hi - 1.0).abs() < 0.35 && gap < sh * 3.0);
|
||||
let on_seg = gap < s.h * 1.2 && (is_script_on(&s.bbox, s.h, &g, hi) || is_script_on(&g, hi, &s.bbox, s.h));
|
||||
(same_line || on_seg) && g[0] > glyphs[p].rect[0] - sh * 2.0
|
||||
});
|
||||
let reset = cont && segs.last().is_some_and(|s| restarts_run(&glyphs, s.first.start, i, furthest_progress));
|
||||
if cont
|
||||
@@ -192,11 +197,10 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec<usize>, Vec<u32>, Vec<bool>) {
|
||||
&& let Some(s) = segs.last_mut()
|
||||
{
|
||||
s.first.end = i + 1;
|
||||
union(&mut s.bbox, &g);
|
||||
s.h = s.h.max(height(i));
|
||||
s.extend_box(&g, hi);
|
||||
furthest_progress = furthest_progress.max((g[0] - run_start_x).abs()).max((g[2] - run_start_x).abs());
|
||||
} else {
|
||||
segs.push(Seg::new(Run { start: i, end: i + 1 }, g, height(i)));
|
||||
segs.push(Seg::new(Run { start: i, end: i + 1 }, g, hi));
|
||||
run_start_x = g[0];
|
||||
furthest_progress = 0.0;
|
||||
}
|
||||
@@ -245,7 +249,8 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec<usize>, Vec<u32>, Vec<bool>) {
|
||||
kept_runs
|
||||
};
|
||||
segs.truncate(retained);
|
||||
// Merge same-baseline segments that nearly touch.
|
||||
// Merge same-baseline segments that nearly touch, then attach any remaining superscript or
|
||||
// subscript segments to the closest host line segment they sit on.
|
||||
let mut merged = true;
|
||||
while merged {
|
||||
merged = false;
|
||||
@@ -266,6 +271,30 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec<usize>, Vec<u32>, Vec<bool>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if !merged {
|
||||
let mut b = 0;
|
||||
while b < segs.len() {
|
||||
let sb = &segs[b];
|
||||
let best_host = (0..segs.len())
|
||||
.filter(|&a| {
|
||||
if a == b {
|
||||
return false;
|
||||
}
|
||||
let sa = &segs[a];
|
||||
let gap = (sb.bbox[0] - sa.bbox[2]).max(sa.bbox[0] - sb.bbox[2]);
|
||||
sa.bbox[2] - sa.bbox[0] > sb.bbox[2] - sb.bbox[0] && gap < sa.h * 1.2 && is_script_on(&sa.bbox, sa.h, &sb.bbox, sb.h)
|
||||
})
|
||||
.min_by(|&a1, &a2| (segs[a1].cy() - sb.cy()).abs().total_cmp(&(segs[a2].cy() - sb.cy()).abs()));
|
||||
if let Some(a) = best_host {
|
||||
let sb = segs.remove(b);
|
||||
let a = if b < a { a - 1 } else { a };
|
||||
segs[a].merge(sb);
|
||||
merged = true;
|
||||
} else {
|
||||
b += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Blocks.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::layout::{is_rtl, union};
|
||||
use super::*;
|
||||
use super::TextGlyph;
|
||||
use super::layout::is_rtl;
|
||||
|
||||
/// Glyphs `start..end` of the glyph list [`arrange`] lays out, drawn one after another.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -37,6 +37,32 @@ struct SegExtra {
|
||||
more: Vec<Run>,
|
||||
}
|
||||
|
||||
/// Whether `(script_bbox, script_h)` is a superscript or subscript on `(host_bbox, host_h)`.
|
||||
pub(super) fn is_script_on(host_bbox: &[f32; 4], host_h: f32, script_bbox: &[f32; 4], script_h: f32) -> bool {
|
||||
let host_cy = (host_bbox[1] + host_bbox[3]) / 2.0;
|
||||
let script_cy = (script_bbox[1] + script_bbox[3]) / 2.0;
|
||||
let script_w = script_bbox[2] - script_bbox[0];
|
||||
let host_w = host_bbox[2] - host_bbox[0];
|
||||
let smaller = script_h >= host_h * 0.45 && script_w < host_h * 3.0 && (script_h < host_h * 0.9 || (script_h <= host_h && host_w > script_w));
|
||||
if !smaller {
|
||||
return false;
|
||||
}
|
||||
if script_cy < host_cy {
|
||||
// Superscript: raised above the host baseline, with its bottom inside the host line.
|
||||
host_cy - script_cy >= host_h * 0.12
|
||||
&& host_cy - script_cy < host_h * 0.7
|
||||
&& host_bbox[3] - script_bbox[3] >= host_h * 0.15
|
||||
&& script_bbox[3] > host_bbox[1] + host_h * 0.25
|
||||
} else {
|
||||
// Subscript: lowered below the host baseline, with its centre still inside the host line.
|
||||
script_cy - host_cy >= host_h * 0.12
|
||||
&& script_cy - host_cy < host_h * 0.5
|
||||
&& script_bbox[3] > host_bbox[3]
|
||||
&& host_bbox[3] > script_bbox[1] + script_h * 0.5
|
||||
&& script_bbox[1] >= host_bbox[1] - script_h * 0.2
|
||||
}
|
||||
}
|
||||
|
||||
impl Seg {
|
||||
pub(super) fn new(first: Run, bbox: [f32; 4], h: f32) -> Self {
|
||||
Seg { first, extra: None, bbox, h }
|
||||
@@ -50,9 +76,25 @@ impl Seg {
|
||||
std::iter::once(self.first).chain(self.extra.iter().flat_map(|e| e.more.iter().copied()))
|
||||
}
|
||||
|
||||
/// Extend `self`'s box and height with `(bbox, h)`. A smaller script (superscript or
|
||||
/// subscript) widens the line horizontally without pulling its vertical baseline band up or
|
||||
/// down; when a line begins with a script and continues with taller body text, the body text's
|
||||
/// vertical band becomes the line's.
|
||||
pub(super) fn extend_box(&mut self, bbox: &[f32; 4], h: f32) {
|
||||
if h > self.h * 1.15 || is_script_on(bbox, h, &self.bbox, self.h) {
|
||||
self.bbox[1] = bbox[1];
|
||||
self.bbox[3] = bbox[3];
|
||||
} else if !(self.h > h * 1.15 || is_script_on(&self.bbox, self.h, bbox, h)) {
|
||||
self.bbox[1] = self.bbox[1].min(bbox[1]);
|
||||
self.bbox[3] = self.bbox[3].max(bbox[3]);
|
||||
}
|
||||
self.bbox[0] = self.bbox[0].min(bbox[0]);
|
||||
self.bbox[2] = self.bbox[2].max(bbox[2]);
|
||||
self.h = self.h.max(h);
|
||||
}
|
||||
|
||||
pub(super) fn merge(&mut self, other: Seg) {
|
||||
union(&mut self.bbox, &other.bbox);
|
||||
self.h = self.h.max(other.h);
|
||||
self.extend_box(&other.bbox, other.h);
|
||||
let extra = self.extra.get_or_insert_with(Default::default);
|
||||
extra.more.push(other.first);
|
||||
if let Some(o) = other.extra {
|
||||
@@ -274,8 +316,10 @@ pub(super) fn collide(glyphs: &[&TextGlyph], height: &dyn Fn(usize) -> f32, h: f
|
||||
}
|
||||
budget -= 1;
|
||||
let (ra, rb) = (union_find(&mut sc.parent, ri), union_find(&mut sc.parent, rj));
|
||||
let hj = height(j);
|
||||
let script = is_script_on(&glyphs[i].rect, hi, &glyphs[j].rect, hj) || is_script_on(&glyphs[j].rect, hj, &glyphs[i].rect, hi);
|
||||
let overlap = x1.min(glyphs[j].rect[2]) - x0(j);
|
||||
if ra != rb && overlap > RUN_COLLISION * hi.min(height(j)) {
|
||||
if ra != rb && !script && overlap > RUN_COLLISION * hi.min(hj) {
|
||||
sc.parent[ra] = rb;
|
||||
collided = true;
|
||||
}
|
||||
|
||||
@@ -1759,3 +1759,209 @@ fn truetype_without_an_encoding_reads_its_codes_as_win_ansi() {
|
||||
assert_eq!(ocr_layer_text(4, false), expected, "symbolic, no /ToUnicode");
|
||||
assert_eq!(ocr_layer_text(32, true), expected, "nonsymbolic, /ToUnicode for ASCII only");
|
||||
}
|
||||
|
||||
/// Issue #2929: a footnote reference (superscript) on a line stays in that line instead of
|
||||
/// splitting out as a raised segment that steals the block's tail and pushes the host line
|
||||
/// after the paragraph.
|
||||
#[test]
|
||||
fn paragraph_selection_includes_lines_with_footnote_references() {
|
||||
for inline in [true, false] {
|
||||
let ops = if inline {
|
||||
"BT /F1 12 Tf 1 0 0 1 40 300 Tm (First line of the paragraph) Tj \
|
||||
1 0 0 1 40 285 Tm (Second line with a note) Tj \
|
||||
/F1 8 Tf 5.5 Ts (1) Tj 0 Ts \
|
||||
/F1 12 Tf ( and more text) Tj \
|
||||
1 0 0 1 40 270 Tm (Third line of the paragraph) Tj \
|
||||
/F1 7 Tf 1 0 0 1 40 104 Tm (1) Tj \
|
||||
/F1 10 Tf 1 0 0 1 47 100 Tm (Footnote at the bottom) Tj ET"
|
||||
} else {
|
||||
// Superscripts drawn in a separate pass after the body text.
|
||||
"BT /F1 12 Tf 1 0 0 1 40 300 Tm (First line of the paragraph) Tj \
|
||||
1 0 0 1 40 285 Tm (Second line with a note) Tj \
|
||||
1 0 0 1 173 285 Tm (and more text) Tj \
|
||||
1 0 0 1 40 270 Tm (Third line of the paragraph) Tj \
|
||||
/F1 10 Tf 1 0 0 1 47 100 Tm (Footnote at the bottom) Tj \
|
||||
/F1 8 Tf 1 0 0 1 165 290.5 Tm (1) Tj \
|
||||
/F1 7 Tf 1 0 0 1 40 104 Tm (1) Tj ET"
|
||||
};
|
||||
let text = page_glyphs(ops);
|
||||
let start = text.nearest(40.0, 95.0).unwrap();
|
||||
let end = text.nearest(185.0, 128.0).unwrap();
|
||||
assert_eq!(
|
||||
text.text_of(start..end + 1),
|
||||
"First line of the paragraph\nSecond line with a note1 and more text\nThird line of the paragraph",
|
||||
"inline={inline}"
|
||||
);
|
||||
assert_eq!(text.line_rects(start..end + 1).len(), 3, "three selected lines, inline={inline}");
|
||||
assert_eq!(
|
||||
text.plain_text(),
|
||||
"First line of the paragraph\nSecond line with a note1 and more text\nThird line of the paragraph\n1 Footnote at the bottom",
|
||||
"inline={inline}"
|
||||
);
|
||||
}
|
||||
|
||||
// Superscript on the first line, multi-character bracketed reference `[12]` on the second
|
||||
// line, and subscript on the third line (both inline and out-of-order).
|
||||
for inline in [true, false] {
|
||||
let ops = if inline {
|
||||
"BT /F1 12 Tf 1 0 0 1 40 300 Tm (Opening claim) Tj \
|
||||
/F1 8 Tf 5.5 Ts (1) Tj 0 Ts \
|
||||
/F1 12 Tf ( in the first line) Tj \
|
||||
1 0 0 1 40 285 Tm (Middle citation) Tj \
|
||||
/F1 8 Tf 5.5 Ts ([12]) Tj 0 Ts \
|
||||
/F1 12 Tf ( continues here) Tj \
|
||||
1 0 0 1 40 270 Tm (Formula H) Tj \
|
||||
/F1 8 Tf -3 Ts (2) Tj 0 Ts \
|
||||
/F1 12 Tf (O in the third line) Tj ET"
|
||||
} else {
|
||||
"BT /F1 12 Tf 1 0 0 1 40 300 Tm (Opening claim) Tj \
|
||||
1 0 0 1 125 300 Tm (in the first line) Tj \
|
||||
1 0 0 1 40 285 Tm (Middle citation) Tj \
|
||||
1 0 0 1 135 285 Tm (continues here) Tj \
|
||||
1 0 0 1 40 270 Tm (Formula H) Tj \
|
||||
1 0 0 1 101 270 Tm (O in the third line) Tj \
|
||||
/F1 8 Tf 1 0 0 1 117 305.5 Tm (1) Tj \
|
||||
1 0 0 1 117 290.5 Tm ([12]) Tj \
|
||||
1 0 0 1 96.5 267 Tm (2) Tj ET"
|
||||
};
|
||||
let text = page_glyphs(ops);
|
||||
assert_eq!(
|
||||
text.plain_text(),
|
||||
"Opening claim1 in the first line\nMiddle citation[12] continues here\nFormula H2O in the third line",
|
||||
"inline={inline}"
|
||||
);
|
||||
assert_eq!(text.line_rects(0..text.glyphs.len()).len(), 3, "inline={inline}");
|
||||
}
|
||||
|
||||
// Justified wide gap (18pt = 1.5 * h) after an inline superscript stays on the same line,
|
||||
// and an out-of-order superscript kerned slightly over punctuation does not break the host
|
||||
// word via overprint collision.
|
||||
let justified = page_glyphs(
|
||||
"BT /F1 12 Tf 1 0 0 1 40 300 Tm (Justified clause.) Tj \
|
||||
/F1 8 Tf 5.5 Ts (1) Tj 0 Ts \
|
||||
/F1 12 Tf 1 0 0 1 151 300 Tm (Next sentence on the same line) Tj \
|
||||
1 0 0 1 40 285 Tm (Kerned clause.) Tj \
|
||||
1 0 0 1 130 285 Tm (continues cleanly) Tj \
|
||||
/F1 8 Tf 1 0 0 1 118 290.5 Tm (2) Tj ET",
|
||||
);
|
||||
assert_eq!(justified.plain_text(), "Justified clause.1 Next sentence on the same line\nKerned clause.2 continues cleanly");
|
||||
assert_eq!(justified.line_rects(0..justified.glyphs.len()).len(), 2);
|
||||
|
||||
// A line that begins with a raised superscript marker and continues with taller body text
|
||||
// adopts the body text's vertical band (Seg::extend_box first branch) so a later
|
||||
// out-of-order superscript on that line still measures against the body baseline.
|
||||
let leading_script = page_glyphs(
|
||||
"BT /F1 8 Tf 1 0 0 1 40 300 Tm 6 Ts (1) Tj 0 Ts \
|
||||
/F1 12 Tf ( See the appendix) Tj \
|
||||
1 0 0 1 146 300 Tm (for details) Tj \
|
||||
/F1 8 Tf 1 0 0 1 139 303.6 Tm (2) Tj ET",
|
||||
);
|
||||
assert_eq!(leading_script.plain_text(), "1 See the appendix2 for details");
|
||||
let marker = leading_script.glyphs.first().unwrap().rect;
|
||||
let body = leading_script.glyphs.iter().find(|g| g.text == "S").unwrap().rect;
|
||||
let last = leading_script.glyphs.last().unwrap().rect;
|
||||
let center = |r: [f32; 4]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0);
|
||||
let (start_x, start_y) = center(marker);
|
||||
let (end_x, end_y) = center(last);
|
||||
let start = leading_script.nearest(start_x, start_y).unwrap();
|
||||
let end = leading_script.nearest(end_x, end_y).unwrap();
|
||||
assert_eq!(leading_script.text_of(start..end + 1), "1 See the appendix2 for details");
|
||||
let rects = leading_script.line_rects(start..end + 1);
|
||||
assert_eq!(rects.len(), 1);
|
||||
assert!(marker[1] < body[1] && marker[3] < body[3], "marker must be raised above the body");
|
||||
let close = |actual: f32, expected: f32| assert!((actual - expected).abs() < 0.001, "{actual} != {expected}");
|
||||
// Highlight rectangles cover the actual glyphs, including the raised marker. They do
|
||||
// not expose the segment's baseline band used for line grouping.
|
||||
for (actual, expected) in rects[0].into_iter().zip([marker[0], marker[1], last[2], body[3]]) {
|
||||
close(actual, expected);
|
||||
}
|
||||
close(rects[0][3] - rects[0][1], 14.8);
|
||||
close(body[3] - 0.2 * 12.0, 100.0); // 300 pt PDF baseline on a 400 pt page.
|
||||
|
||||
// Pin that internal band too: replacing the first extend_box condition with `false`
|
||||
// must fail here even when plain text and highlight rectangles remain unchanged.
|
||||
let mut segment = Seg::new(Run { start: 0, end: 1 }, marker, marker[3] - marker[1]);
|
||||
segment.extend_box(&body, body[3] - body[1]);
|
||||
for (actual, expected) in segment.bbox.into_iter().zip([marker[0], body[1], body[2], body[3]]) {
|
||||
close(actual, expected);
|
||||
}
|
||||
close(segment.h, 12.0);
|
||||
close(segment.bbox[3] - segment.bbox[1], 12.0);
|
||||
close(segment.bbox[3] - 0.2 * segment.h, 100.0);
|
||||
close(segment.cy(), (body[1] + body[3]) / 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_outside_the_crop_box_is_clipped() {
|
||||
use lopdf::{Document, Object, Stream, dictionary};
|
||||
let ops = [
|
||||
"BT /F1 12 Tf 1 0 0 1 100 200 Tm (Inside crop) Tj ET",
|
||||
"BT /F1 12 Tf 1 0 0 1 100 150 Tm /Span <</ActualText (ActualIn)>> BDC (in) Tj EMC ET",
|
||||
// Keep every glyph of OutSpan outside the crop; a boundary-crossing glyph remains visible.
|
||||
"BT /F1 12 Tf 1 0 0 1 -100 125 Tm /Span <</ActualText (CroppedSpan KeptSpan)>> BDC (OutSpan) Tj 1 0 0 1 100 125 Tm (KeptSpan) Tj EMC ET",
|
||||
"BT /F1 12 Tf 1 0 0 1 5 200 Tm (LeftOut) Tj ET",
|
||||
"BT /F1 12 Tf 1 0 0 1 360 200 Tm (RightOut) Tj ET",
|
||||
"BT /F1 12 Tf 1 0 0 1 100 20 Tm (BelowOut) Tj ET",
|
||||
"BT /F1 12 Tf 1 0 0 1 100 370 Tm (AboveOut) Tj ET",
|
||||
"BT /F1 12 Tf 1 0 0 1 100 20 Tm /Span <</ActualText (ActualOut)>> BDC (out) Tj EMC ET",
|
||||
"BT /F1 12 Tf 1 0 0 1 46 100 Tm (A) Tj ET",
|
||||
]
|
||||
.join("\n");
|
||||
for rotate in [0, 90, 180, 270] {
|
||||
for unit in [1, 2] {
|
||||
let mut doc = Document::with_version("1.7");
|
||||
let font = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica" });
|
||||
let content = doc.add_object(Stream::new(dictionary! {}, ops.as_bytes().to_vec()));
|
||||
let pages = doc.new_object_id();
|
||||
let page = doc.add_object(dictionary! {
|
||||
"Type" => "Page",
|
||||
"Parent" => pages,
|
||||
"MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(400), Object::Integer(400)],
|
||||
"CropBox" => vec![Object::Integer(50), Object::Integer(60), Object::Integer(350), Object::Integer(340)],
|
||||
"Rotate" => rotate,
|
||||
"UserUnit" => unit,
|
||||
"Resources" => dictionary! { "Font" => dictionary! { "F1" => font } },
|
||||
"Contents" => content,
|
||||
});
|
||||
doc.objects.insert(pages, dictionary! { "Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1 }.into());
|
||||
let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages });
|
||||
doc.trailer.set("Root", catalog);
|
||||
let mut bytes = Vec::new();
|
||||
doc.save_to(&mut bytes).unwrap();
|
||||
let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap();
|
||||
let text = extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap();
|
||||
assert_eq!(text.plain_text(), "Inside crop\nActualIn\nKeptSpan\nA", "rotate={rotate} unit={unit}");
|
||||
assert!(!text.find("Inside").is_empty(), "rotate={rotate} unit={unit}");
|
||||
assert!(!text.find("ActualIn").is_empty(), "rotate={rotate} unit={unit}");
|
||||
assert!(!text.find("KeptSpan").is_empty(), "rotate={rotate} unit={unit}");
|
||||
for hidden in ["LeftOut", "RightOut", "BelowOut", "AboveOut", "ActualOut", "CroppedSpan", "OutSpan"] {
|
||||
assert!(text.find(hidden).is_empty(), "{hidden} must be clipped at rotate={rotate} unit={unit}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A CropBox that is zero-area or disjoint from the MediaBox leaves an empty visible region,
|
||||
// even when a glyph straddles the clamped intersection point.
|
||||
for crop in [[200, 200, 200, 200], [100, 200, 300, 200], [500, 500, 600, 600]] {
|
||||
let mut doc = Document::with_version("1.7");
|
||||
let font = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica" });
|
||||
let content = doc.add_object(Stream::new(dictionary! {}, ops.as_bytes().to_vec()));
|
||||
let pages = doc.new_object_id();
|
||||
let page = doc.add_object(dictionary! {
|
||||
"Type" => "Page",
|
||||
"Parent" => pages,
|
||||
"MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(400), Object::Integer(400)],
|
||||
"CropBox" => crop.map(Object::Integer).to_vec(),
|
||||
"Resources" => dictionary! { "Font" => dictionary! { "F1" => font } },
|
||||
"Contents" => content,
|
||||
});
|
||||
doc.objects.insert(pages, dictionary! { "Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1 }.into());
|
||||
let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages });
|
||||
doc.trailer.set("Root", catalog);
|
||||
let mut bytes = Vec::new();
|
||||
doc.save_to(&mut bytes).unwrap();
|
||||
let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap();
|
||||
let text = extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap();
|
||||
assert!(text.is_empty(), "expected no glyphs on {crop:?}, got {:?}", text.plain_text());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user