refactor(edit): split added.rs into added/

New modules: types, layout, draw, store.
This commit is contained in:
aiongg
2026-10-26 22:18:50 +00:00
parent 157cbf3a9d
commit 36a68fc15e
5 changed files with 1265 additions and 1237 deletions
+13 -1237
View File
File diff suppressed because it is too large Load Diff
+383
View File
@@ -0,0 +1,383 @@
use super::layout::{
Piece, arabic_error, hebrew_error, is_arabic, is_hebrew, is_thai, latin_width, lines, norm, shaped_layout, text_rect, thai_error, wrapped,
};
use super::types::{AddedText, Align, Family};
use super::*;
fn font_name(base: &str) -> String {
format!("PCF{}", base.replace('-', ""))
}
/// Most glyphs in one Type 3 font: its codes are single bytes, 1–240. An item gets one such font
/// per 240 distinct glyphs it needs.
pub(super) const GLYPHS_PER_FONT: usize = 240;
/// A line of a shaped-text item after layout: its text, whether its paragraph runs right to left,
/// its pieces in drawing order and its width at the item's size.
pub(super) type LaidLine = (String, bool, Vec<Piece>, f64);
/// Draw a text item that has Arabic, Hebrew or Thai in it. Each line is laid out in display order; their glyphs
/// come from Type 3 fonts drawn from the craft-fonts face's outlines (no font file is embedded),
/// with a ToUnicode map for copy and search. Other text uses the item's standard font.
fn draw_shaped(doc: &mut Document, t: &AddedText, taken: &Dict, fonts: &mut Dict, out: &mut Vec<u8>) -> Result<(), EditError> {
let laid = wrapped(t)
.into_iter()
.map(|(line, rtl)| shaped_layout(t, &line, rtl).map(|(pieces, w)| (line, rtl, pieces, w)))
.collect::<Result<Vec<_>, _>>()?;
draw_laid_shaped(doc, t, &laid, taken, fonts, out, shaped_glyph)
}
/// [`draw_shaped`] for lines that are already laid out. `outline` is the source of glyph outlines
/// for a script's face (the craft-fonts faces, or a stand-in in tests).
pub(super) fn draw_laid_shaped(
doc: &mut Document,
t: &AddedText,
laid: &[LaidLine],
taken: &Dict,
fonts: &mut Dict,
out: &mut Vec<u8>,
outline: impl Fn(ShapedScript, u32) -> Result<GlyphOutline, GlyphError>,
) -> Result<(), EditError> {
let r = text_rect(t);
let latin = font_name(t.family.base_font(t.bold, t.italic));
// Every distinct cluster (a letter with its dots and the text it stands for) is one glyph of
// one of the fonts, so copy and search get each character exactly once.
let mut keys: Vec<&ShapedCluster> = Vec::new();
let mut index: HashMap<ClusterKey, usize> = HashMap::new();
for c in laid.iter().flat_map(|(_, _, pieces, _)| pieces).flat_map(|p| match p {
Piece::Shaped(clusters) => clusters.as_slice(),
Piece::Latin(_) => &[],
}) {
index.entry(cluster_key(c)).or_insert_with(|| {
keys.push(c);
keys.len() - 1
});
}
// Every outline is read before anything is written, so a failure leaves the document as it was.
let mut outlines = HashMap::new();
for c in &keys {
for (id, _) in &c.glyphs {
if let std::collections::hash_map::Entry::Vacant(entry) = outlines.entry((c.script, *id)) {
entry.insert(outline(c.script, *id).map_err(|e| match c.script {
ShapedScript::Arabic => arabic_error(e, &c.text),
ShapedScript::Hebrew => hebrew_error(e, &c.text),
ShapedScript::Thai => thai_error(e, &c.text),
})?);
}
}
}
// The names the page and this item already use: a new font never takes one of them.
let mut taken_names: HashSet<Vec<u8>> = taken.iter().chain(fonts.iter()).map(|(name, _)| name.clone()).collect();
let mut names = Vec::new();
for chunk in keys.chunks(GLYPHS_PER_FONT) {
let font = type3_shaped(doc, chunk, &outlines);
// A name no font on the page has, so items never replace each other's fonts (object
// numbers alone don't do: a full save renumbers objects but keeps resource names).
let mut name = format!("PCAr{}", font.num);
let mut suffix = 0usize;
while taken_names.contains(name.as_bytes()) {
suffix = suffix.saturating_add(1);
name = format!("PCAr{}_{suffix}", font.num);
}
taken_names.insert(name.clone().into_bytes());
fonts.set(name.clone().into_bytes(), Object::Ref(font));
names.push(name);
}
let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0));
out.extend(format!("BT {} {} {} rg\n", n(cr), n(cg), n(cb)).bytes());
if t.char_spacing != 0.0 {
out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes());
}
for (i, (line, rtl, pieces, w)) in laid.iter().enumerate() {
// Shaped glyphs are painted in visual order. Keep the original line's typing order
// explicitly, including marks, ligatures, digits and mixed-script punctuation.
let logical: String = line.encode_utf16().map(|u| format!("{u:04X}")).collect();
out.extend(format!("/Span <</ActualText <FEFF{logical}>>> BDC\n").bytes());
let spaces = line.matches(' ').count();
// Justified: the spaces of every line but the last stretch to fill the box.
let tw = if t.align == Align::Justify && i + 1 < laid.len() && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 };
let x = match t.align {
// A right-to-left paragraph's unstretched last line ends at the right edge.
Align::Justify if *rtl => r[2] - w - tw * spaces as f64,
Align::Left | Align::Justify => r[0],
Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0,
Align::Right => r[2] - w,
};
let y = r[3] - (i as f64 * 1.2 + 0.95) * t.size;
let mut pen = x;
let mut current: Option<&str> = None;
for piece in pieces {
match piece {
Piece::Latin(s) => {
out.extend(format!("/{latin} {} Tf {} Tw 1 0 0 1 {} {} Tm ", n(t.size), n(tw), n(pen), n(y)).bytes());
out.extend(literal(&win_ansi(s)));
out.extend_from_slice(b" Tj\n");
current = Some(latin.as_str());
pen += latin_width(t, s) + tw * s.matches(' ').count() as f64;
}
Piece::Shaped(clusters) => {
for c in clusters {
let Some(&k) = index.get(&cluster_key(c)) else { continue };
let Some(name) = names.get(k / GLYPHS_PER_FONT) else { continue };
// Codes start at 1; k % 240 + 1 is at most 240.
let code = u8::try_from(k % GLYPHS_PER_FONT + 1).unwrap_or(1);
if current != Some(name.as_str()) {
out.extend(format!("/{name} {} Tf ", n(t.size)).bytes());
current = Some(name.as_str());
}
out.extend(format!("1 0 0 1 {} {} Tm ", n(pen), n(y)).bytes());
out.extend(literal(&[code]));
out.extend_from_slice(b" Tj\n");
pen += c.advance * t.size + t.char_spacing + if c.text == " " { tw } else { 0.0 };
}
}
}
}
out.extend_from_slice(b"EMC\n");
}
out.extend_from_slice(b"ET\n");
Ok(())
}
/// What makes two clusters the same Type 3 glyph: text, glyphs, offsets and advance (bit for bit;
/// equal clusters come from the same font units).
type ClusterKey<'a> = (ShapedScript, &'a str, Vec<(u32, u64, u64)>, u64);
fn cluster_key(c: &ShapedCluster) -> ClusterKey<'_> {
(c.script, c.text.as_str(), c.glyphs.iter().map(|(id, [x, y])| (*id, x.to_bits(), y.to_bits())).collect(), c.advance.to_bits())
}
/// A Type 3 font with `clusters` as codes 1…, each drawn from its script face's glyph `outlines`.
fn type3_shaped(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &HashMap<(ShapedScript, u32), GlyphOutline>) -> ObjRef {
let mut charprocs = Dict::new();
let mut differences = vec![Object::Int(1)];
let mut widths = Vec::with_capacity(clusters.len());
let mut bbox = [0.0f64; 4];
let mut to_unicode = Vec::new();
for (i, c) in clusters.iter().enumerate() {
let code = i + 1;
// Glyph space is 1000 units per em.
let mut paths = Vec::new();
let mut ink: Option<[f64; 4]> = None;
for (id, [dx, dy]) in &c.glyphs {
let Some(g) = outlines.get(&(c.script, *id)) else { continue };
for contour in &g.contours {
for (j, p) in contour.iter().enumerate() {
paths.extend(format!("{} {} {}\n", n((p[0] + dx) * 1000.0), n((p[1] + dy) * 1000.0), if j == 0 { "m" } else { "l" }).bytes());
}
paths.extend_from_slice(b"h\n");
let b = [g.bbox[0] + dx, g.bbox[1] + dy, g.bbox[2] + dx, g.bbox[3] + dy];
ink = Some(ink.map_or(b, |a| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]));
}
}
// Rounded outwards so no ink is clipped.
let [x0, y0, x1, y1] =
ink.map_or([0.0; 4], |b| [(b[0] * 1000.0).floor(), (b[1] * 1000.0).floor(), (b[2] * 1000.0).ceil(), (b[3] * 1000.0).ceil()]);
bbox = [bbox[0].min(x0), bbox[1].min(y0), bbox[2].max(x1), bbox[3].max(y1)];
let mut proc = format!("{} 0 {} {} {} {} d1\n", n(c.advance * 1000.0), n(x0), n(y0), n(x1), n(y1)).into_bytes();
if !paths.is_empty() {
proc.extend(paths);
proc.extend_from_slice(b"f\n");
}
let glyph = format!("g{code:02X}");
charprocs.set(glyph.clone().into_bytes(), Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &proc)))));
differences.push(Object::name(&glyph));
widths.push(Object::Real((c.advance * 1000.0).round()));
if !c.text.is_empty() {
let hex: String = c.text.encode_utf16().map(|u| format!("{u:04X}")).collect();
to_unicode.push(format!("<{code:02X}> <{hex}>\n"));
}
}
let mut cmap = String::from(
"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CMapType 2 def\n1 begincodespacerange\n<01> <FF>\nendcodespacerange\n",
);
// A CMap block holds at most 100 entries.
for block in to_unicode.chunks(100) {
cmap.push_str(&format!("{} beginbfchar\n{}endbfchar\n", block.len(), block.concat()));
}
cmap.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n");
let cmap = doc.add(Object::Stream(Stream::flate(Dict::new(), cmap.as_bytes())));
// The face's family when every glyph comes from one script; without the face, the script's
// name (as Arabic items were always named).
let family = match clusters.first().map(|c| c.script).filter(|s| clusters.iter().all(|c| c.script == *s)) {
Some(script) => script.font().map_or(
match script {
ShapedScript::Arabic => "Arabic",
ShapedScript::Hebrew => "Hebrew",
ShapedScript::Thai => "Thai",
},
|f| f.family,
),
None => "PdfCraft Shaped Text",
};
let mut descriptor = Dict::new();
descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor"));
descriptor.set(b"FontName".to_vec(), Object::name(&family.replace(' ', "")));
descriptor.set(b"FontFamily".to_vec(), Object::String(PdfString::literal(family.as_bytes().to_vec())));
descriptor.set(b"Flags".to_vec(), Object::Int(4));
descriptor.set(b"ItalicAngle".to_vec(), Object::Int(0));
let mut encoding = Dict::new();
encoding.set(b"Type".to_vec(), Object::name("Encoding"));
encoding.set(b"Differences".to_vec(), Object::Array(differences));
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type3"));
font.set(b"FontDescriptor".to_vec(), Object::Ref(doc.add(Object::Dict(descriptor))));
font.set(b"FontBBox".to_vec(), Object::Array(bbox.iter().map(|v| Object::Real(*v)).collect()));
font.set(
b"FontMatrix".to_vec(),
Object::Array(vec![Object::Real(0.001), Object::Int(0), Object::Int(0), Object::Real(0.001), Object::Int(0), Object::Int(0)]),
);
font.set(b"FirstChar".to_vec(), Object::Int(1));
font.set(b"LastChar".to_vec(), Object::Int(clusters.len() as i64));
font.set(b"Widths".to_vec(), Object::Array(widths));
font.set(b"Encoding".to_vec(), Object::Dict(encoding));
font.set(b"CharProcs".to_vec(), Object::Dict(charprocs));
font.set(b"ToUnicode".to_vec(), Object::Ref(cmap));
doc.add(Object::Dict(font))
}
/// Content and resources for an item; `view` maps display space to user space. `taken` holds the
/// page's font names. `existing`: the item is being replaced, so a build without the Arabic or
/// Hebrew face keeps drawing that text as `?`, as before it was supported, rather than refusing to
/// move it.
pub(super) fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing: bool) -> Result<(Vec<u8>, Dict), EditError> {
let mut res = Dict::new();
let mut out = format!(
"q {} {} {} {} {} {} cm\n",
matrix_number(view[0]),
matrix_number(view[1]),
matrix_number(view[2]),
matrix_number(view[3]),
matrix_number(view[4]),
matrix_number(view[5])
)
.into_bytes();
match c {
Content::Text(t) => {
let base = t.family.base_font(t.bold, t.italic);
let name = font_name(base);
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type1"));
font.set(b"BaseFont".to_vec(), Object::name(base));
if t.family != Family::Courier || !t.text.is_ascii() {
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
}
let mut fonts = Dict::new();
fonts.set(name.clone().into_bytes(), Object::Dict(font));
let face_or_new = |has: fn(char) -> bool, face: bool| t.text.chars().any(has) && (face || !existing);
if t.text.chars().any(is_thai)
|| face_or_new(is_arabic, pdfcraft_fonts::document_arabic_font().is_some())
|| face_or_new(is_hebrew, pdfcraft_fonts::document_hebrew_font(false).is_some())
{
draw_shaped(doc, t, taken, &mut fonts, &mut out)?;
res.set(b"Font".to_vec(), Object::Dict(fonts));
out.extend_from_slice(b"Q\n");
return Ok((out, res));
}
res.set(b"Font".to_vec(), Object::Dict(fonts));
let r = text_rect(t);
let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0));
out.extend(format!("BT /{name} {} Tf {} {} {} rg\n", n(t.size), n(cr), n(cg), n(cb)).bytes());
if t.char_spacing != 0.0 {
out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes());
}
let all = lines(t);
for (i, line) in all.iter().enumerate() {
let w = latin_width(t, line);
let x = match t.align {
Align::Left | Align::Justify => r[0],
Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0,
Align::Right => r[2] - w,
};
// Baseline: 0.8 em below the line top.
let y = r[3] - (i as f64 * 1.2 + 0.95) * t.size;
// Justified: word spacing makes every line but the last fill the box.
let spaces = line.matches(' ').count();
let tw =
if t.align == Align::Justify && i + 1 < all.len() && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 };
out.extend(format!("1 0 0 1 {} {} Tm {} Tw ", n(x), n(y), n(tw)).bytes());
out.extend(literal(&win_ansi(line)));
out.extend_from_slice(b" Tj\n");
}
out.extend_from_slice(b"ET\n");
}
Content::Image(i) => {
if !matches!(&*doc.get(i.image), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image")) {
return Err(EditError::Invalid("not an image".into()));
}
let r = norm(i.rect);
let name = format!("PCImg{}", i.image.num);
let mut xo = Dict::new();
xo.set(name.clone().into_bytes(), Object::Ref(i.image));
res.set(b"XObject".to_vec(), Object::Dict(xo));
let [a, b, c, d, e, f] = i.matrix().0;
// Clip to the box: the cropped-away parts stay hidden.
out.extend(
format!(
"{} {} {} {} re W n {} {} {} {} {} {} cm /{name} Do\n",
n(r[0]),
n(r[1]),
n(r[2] - r[0]),
n(r[3] - r[1]),
n(a),
n(b),
n(c),
n(d),
n(e),
n(f)
)
.bytes(),
);
}
}
out.extend_from_slice(b"Q\n");
Ok((out, res))
}
pub(super) fn params(c: &Content) -> Dict {
let mut d = Dict::new();
let arr = |v: &[f64]| Object::Array(v.iter().map(|x| Object::Real(*x)).collect());
match c {
Content::Text(t) => {
d.set(b"Kind".to_vec(), Object::name("Text"));
d.set(b"Text".to_vec(), PdfString::text(&t.text));
d.set(b"Font".to_vec(), Object::name(t.family.base_font(t.bold, t.italic)));
d.set(b"Size".to_vec(), Object::Real(t.size));
d.set(b"Color".to_vec(), arr(&t.color));
d.set(
b"Align".to_vec(),
Object::Int(match t.align {
Align::Left => 0,
Align::Center => 1,
Align::Right => 2,
Align::Justify => 3,
}),
);
d.set(b"Rect".to_vec(), arr(&text_rect(t)));
if t.char_spacing != 0.0 {
d.set(b"CharSpacing".to_vec(), Object::Real(t.char_spacing));
}
}
Content::Image(i) => {
d.set(b"Kind".to_vec(), Object::name("Image"));
d.set(b"Image".to_vec(), Object::Ref(i.image));
d.set(b"Rect".to_vec(), arr(&norm(i.rect)));
if i.rotation % 4 != 0 {
d.set(b"Rotate".to_vec(), Object::Int(i64::from(i.rotation % 4) * 90));
}
if i.flip_h {
d.set(b"FlipH".to_vec(), Object::Bool(true));
}
if i.flip_v {
d.set(b"FlipV".to_vec(), Object::Bool(true));
}
if i.crop != [0.0; 4] {
d.set(b"Crop".to_vec(), arr(&i.crop));
}
}
}
d
}
+356
View File
@@ -0,0 +1,356 @@
use super::types::{AddedImage, AddedText};
use super::*;
pub(super) fn nums(doc: &Document, o: Option<&Object>) -> Vec<f64> {
o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default()
}
pub(super) fn norm(r: [f64; 4]) -> [f64; 4] {
[r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]
}
/// The advance of `s` in the item's standard font: its glyphs' widths and the character spacing
/// after each of them, as `Tc` moves the pen (ISO 32000-2 §9.3.2).
pub(super) fn latin_width(t: &AddedText, s: &str) -> f64 {
t.family.width(s, t.size, t.bold, t.italic) + t.char_spacing * s.chars().count() as f64
}
/// The lines of a text item after wrapping to its box width.
pub fn lines(t: &AddedText) -> Vec<String> {
wrapped(t).into_iter().map(|(line, _)| line).collect()
}
#[cfg(test)]
thread_local! {
/// How often [`wrapped`] laid a text out on this thread, for the test that keeps it out of per-line loops.
pub(crate) static WRAP_CALLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// [`lines`], each with whether its paragraph runs right to left (its first strong character
/// does).
pub(super) fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
#[cfg(test)]
WRAP_CALLS.with(|c| c.set(c.get().saturating_add(1)));
let width = (t.rect[2] - t.rect[0]).max(t.size);
let mut out = Vec::new();
for para in t.text.split('\n') {
// Arabic or Hebrew: laid out with the bidirectional algorithm and shaped.
let rtl_script = para.chars().any(|c| is_arabic(c) || is_hebrew(c));
let thai = para.chars().any(is_thai);
if thai
&& !rtl_script
&& let Ok(lines) = thai_lines(t, para, width)
{
out.extend(lines.into_iter().map(|line| (line, false)));
continue;
}
let rtl = rtl_script && unicode_bidi::get_base_direction(para) == unicode_bidi::Direction::Rtl;
// Arabic words are shaped one by one (joining stops at spaces), so a line's width is the
// sum of its words' and shaping stays linear in the paragraph's length. The space is the
// paragraph's; a space between Latin words is drawn in the item's font, a few hundredths
// of an em apart.
let measure = |s: &str| if rtl_script || thai { shaped_width(t, s, rtl) } else { latin_width(t, s) };
let space = measure(" ");
let (mut line, mut line_w) = (String::new(), 0.0);
for word in para.split(' ') {
let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
let candidate_w =
if rtl_script || thai { if line.is_empty() { measure(word) } else { line_w + space + measure(word) } } else { measure(&candidate) };
if !line.is_empty() && candidate_w > width {
out.push((std::mem::take(&mut line), rtl));
line = word.to_string();
line_w = measure(word);
} else {
line = candidate;
line_w = candidate_w;
}
}
out.push((line, rtl));
}
out
}
/// Thai wrapping at shaped cluster boundaries never detaches a tone/vowel mark from its base.
/// This is glyph-safe wrapping, not dictionary-based Thai word segmentation.
fn thai_lines(t: &AddedText, para: &str, width: f64) -> Result<Vec<String>, EditError> {
let (pieces, _) = shaped_layout(t, para, false)?;
let mut atoms = Vec::new();
for piece in pieces {
match piece {
Piece::Shaped(clusters) => atoms.extend(clusters.into_iter().map(|c| (c.text, c.advance * t.size + t.char_spacing))),
Piece::Latin(s) => atoms.extend(s.chars().map(|c| {
let text = c.to_string();
let w = latin_width(t, &text);
(text, w)
})),
}
}
let mut lines = Vec::new();
let (mut line, mut used) = (String::new(), 0.0);
for (text, advance) in atoms {
if !line.is_empty() && used + advance > width {
lines.push(std::mem::take(&mut line));
used = 0.0;
}
line.push_str(&text);
used += advance;
}
lines.push(line);
Ok(lines)
}
pub(super) fn is_thai(c: char) -> bool {
matches!(u32::from(c), 0x0E00..=0x0E7F)
}
/// Arabic letters, marks, digits and punctuation: an item with any of them is shaped with the
/// craft-fonts Arabic face.
pub(super) fn is_arabic(c: char) -> bool {
matches!(u32::from(c), 0x0600..=0x06FF | 0x0750..=0x077F | 0x0870..=0x08FF | 0xFB50..=0xFDFF | 0xFE70..=0xFEFC)
}
/// Hebrew letters, points, cantillation marks and punctuation (and the presentation forms): an
/// item with any of them is drawn with the craft-fonts Hebrew face.
pub(super) fn is_hebrew(c: char) -> bool {
matches!(u32::from(c), 0x0591..=0x05F4 | 0xFB1D..=0xFB4F)
}
/// The first character of `t` that would be drawn as `?`: outside WinAnsiEncoding and not drawn
/// with the craft-fonts Arabic (#125) or Hebrew (#128) face, or Arabic or Hebrew that the face can't
/// draw (or that has no face to draw it). Page-content tools refuse such text; the Add-text editor
/// keeps it open.
pub fn first_undrawable(t: &AddedText) -> Option<char> {
if t.text.chars().any(is_thai) {
if let Some(c) = t.text.chars().find(|c| is_thai(*c) && !pdfcraft_fonts::thai_has(*c)) {
return Some(c);
}
let rest = AddedText { text: t.text.chars().filter(|c| !is_thai(*c)).collect(), ..t.clone() };
return first_undrawable(&rest);
}
// Arabic or Hebrew that nothing can draw: there's no face, or the face lacks it.
if let Some(c) =
t.text.chars().find(|c| (shaped_arabic(*c) && !pdfcraft_fonts::arabic_has(*c)) || (is_hebrew(*c) && !pdfcraft_fonts::hebrew_has(*c)))
{
return Some(c);
}
if !shaped_by_a_face(t) {
// Drawn line by line in the item's standard font (`\n` splits lines). Without the face
// that is also how `draw` redraws an existing item, so it's checked that way.
return t.text.split('\n').find_map(pdfcraft_fonts::first_non_win_ansi);
}
// What `shaped_layout` leaves to the standard font, exactly as `draw_shaped` splits it.
wrapped(t).into_iter().find_map(|(line, rtl)| {
let (pieces, _) = shaped_layout(t, &line, rtl).ok()?;
pieces.iter().find_map(|p| match p {
Piece::Latin(s) => pdfcraft_fonts::first_non_win_ansi(s),
Piece::Shaped(_) => None,
})
})
}
/// Whether `t` has Arabic or Hebrew that its craft-fonts face draws (so `draw` lays it out with
/// [`draw_shaped`]).
fn shaped_by_a_face(t: &AddedText) -> bool {
(pdfcraft_fonts::document_arabic_font().is_some() && t.text.chars().any(is_arabic))
|| (pdfcraft_fonts::document_hebrew_font(false).is_some() && t.text.chars().any(is_hebrew))
}
/// An Arabic character the face shapes and draws (U+061C, a direction mark, is dropped).
pub(super) fn shaped_arabic(c: char) -> bool {
is_arabic(c) && !is_bidi_control(c)
}
/// Direction marks, embeddings, overrides and isolates: they steer the order and are not drawn.
fn is_bidi_control(c: char) -> bool {
matches!(c, '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}')
}
fn mirrored(c: char) -> char {
match c {
'(' => ')',
')' => '(',
'[' => ']',
']' => '[',
'{' => '}',
'}' => '{',
'<' => '>',
'>' => '<',
'«' => '»',
'»' => '«',
other => other,
}
}
/// A piece of a line with shaped text, in drawing order.
pub(super) enum Piece {
/// Shown in the item's own standard font.
Latin(String),
Shaped(Vec<ShapedCluster>),
}
/// A line of a shaped-text item in drawing order (the Unicode bidirectional algorithm, UAX #9), and
/// its width at the item's size. `rtl` is its paragraph's direction.
pub(super) fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec<Piece>, f64), EditError> {
// unicode-bidi indexes the first level of a line, so an empty one would panic.
if line.is_empty() {
return Ok((Vec::new(), 0.0));
}
let base = if rtl { Level::rtl() } else { Level::ltr() };
let info = ParagraphBidiInfo::new(line, Some(base));
let (levels, runs) = info.visual_runs(0..line.len());
let mut pieces = Vec::new();
for run in runs {
let run_rtl = levels.get(run.start).is_some_and(Level::is_rtl);
// Runs lie on character boundaries; a missing slice is skipped, never a panic.
let Some(text) = line.get(run) else { continue };
// Arabic and Hebrew letters take their face; so do spaces and punctuation between them
// (Hebrew's when they follow Hebrew), which the shaper then mirrors. Latin text (and what
// the faces lack) keeps the item's font.
let mut segments: Vec<(Option<ShapedScript>, String)> = Vec::new();
for c in text.chars().filter(|c| !is_bidi_control(*c)) {
let after_hebrew = segments.last().is_some_and(|(kind, _)| *kind == Some(ShapedScript::Hebrew));
let script = if is_thai(c) {
Some(ShapedScript::Thai)
} else if is_hebrew(c) || (run_rtl && after_hebrew && !c.is_alphanumeric() && pdfcraft_fonts::hebrew_has(c)) {
Some(ShapedScript::Hebrew)
} else if is_arabic(c) || (run_rtl && !c.is_alphanumeric() && pdfcraft_fonts::arabic_has(c)) {
Some(ShapedScript::Arabic)
} else {
None
};
match segments.last_mut() {
Some((kind, s)) if *kind == script => s.push(c),
_ => segments.push((script, c.to_string())),
}
}
if run_rtl {
segments.reverse();
}
for (script, s) in segments {
pieces.push(if let Some(script) = script {
let shaped = match script {
ShapedScript::Arabic => shape_arabic(&s, run_rtl).map_err(|e| arabic_error(e, &s))?,
ShapedScript::Hebrew => shape_hebrew(&s, run_rtl).map_err(|e| hebrew_error(e, &s))?,
ShapedScript::Thai => shape_thai(&s).map_err(|e| thai_error(e, &s))?,
};
Piece::Shaped(shaped)
} else if run_rtl {
Piece::Latin(s.chars().rev().map(mirrored).collect())
} else {
Piece::Latin(s)
});
}
}
let width = pieces
.iter()
.map(|p| match p {
Piece::Latin(s) => latin_width(t, s),
// Each cluster is shown on its own, so the spacing follows each once.
Piece::Shaped(clusters) => clusters.iter().map(|c| c.advance * t.size + t.char_spacing).sum::<f64>(),
})
.sum();
Ok((pieces, width))
}
/// The width of `s` in a shaped-text item; the item's font's estimate if it can't be shaped (drawing
/// then reports why).
fn shaped_width(t: &AddedText, s: &str, rtl: bool) -> f64 {
shaped_layout(t, s, rtl).map(|(_, w)| w).unwrap_or_else(|_| latin_width(t, s))
}
pub(super) fn arabic_error(e: GlyphError, text: &str) -> EditError {
EditError::Invalid(match e {
GlyphError::NoFont => "Arabic text needs PdfCraft's Arabic font, which this build doesn't include \
(to build it in, set CRAFT_FONTS_DIR to a craft-fonts checkout that has an Arab face)"
.into(),
GlyphError::Missing => format!("the Arabic font can't show \"{text}\""),
GlyphError::TooComplex => format!("the Arabic font's glyphs for \"{text}\" are too complex"),
})
}
pub(super) fn hebrew_error(e: GlyphError, text: &str) -> EditError {
EditError::Invalid(match e {
GlyphError::NoFont => "Hebrew text needs PdfCraft's Hebrew font, which this build doesn't include \
(to build it in, set CRAFT_FONTS_DIR to a craft-fonts checkout that has a Hebr face)"
.into(),
GlyphError::Missing => format!("the Hebrew font can't show \"{text}\""),
GlyphError::TooComplex => format!("the Hebrew font's glyphs for \"{text}\" are too complex"),
})
}
pub(super) fn thai_error(e: GlyphError, text: &str) -> EditError {
EditError::Invalid(match e {
GlyphError::NoFont => {
"Thai text needs a Thai font in this build; rebuild with CRAFT_FONTS_DIR pointing to craft-fonts with a Thai face".into()
}
GlyphError::Missing => format!("the Thai font can't show \"{text}\""),
GlyphError::TooComplex => "the Thai glyphs are too complex; use a shorter text box".into(),
})
}
/// The box a text item occupies (height from its lines).
pub fn text_rect(t: &AddedText) -> [f64; 4] {
let r = norm(t.rect);
let h = lines(t).len().max(1) as f64 * t.size * 1.2;
[r[0], r[3] - h, r[2], r[3]]
}
/// How far each paste onto a page already holding the copy is offset, down and to the right, so a
/// copy doesn't hide what it was copied from (points).
pub const PASTE_STEP: f64 = 18.0;
/// Where a pasted item of box `rect` goes on a page of `size` (display space): with its top-left
/// corner at `at`, else where it was, `steps` paste steps ([`PASTE_STEP`]) down and to the right.
/// It is kept on the page; one larger than the page keeps its left and top edges on it. A page
/// or point that isn't a number leaves the box where it was.
pub fn paste_rect(rect: [f64; 4], size: [f64; 2], steps: u32, at: Option<[f64; 2]>) -> [f64; 4] {
let [x0, y0, x1, y1] = norm(rect);
let (w, h) = (x1 - x0, y1 - y0);
let (left, top) = match at {
Some([x, y]) => (x, y),
None => {
let d = PASTE_STEP * f64::from(steps);
(x0 + d, y1 - d)
}
};
if !size.iter().chain(&[left, top, w, h]).all(|v| v.is_finite()) {
return rect;
}
let left = left.min(size[0] - w).max(0.0);
let top = top.max(h).min(size[1]);
[left, top - h, left + w, top]
}
/// Most paste steps [`paste_place`] tries before giving up on finding a free place.
const MAX_PASTE_STEPS: u32 = 64;
/// Where Edit ▸ Paste puts `content` on a page of `size` already holding `items` (display space):
/// where it was, or as many paste steps down and to the right as it takes not to land exactly on
/// a copy of it (the original it was copied from, or an earlier paste).
pub fn paste_place<'a>(items: impl IntoIterator<Item = &'a Content>, content: &Content, size: [f64; 2]) -> [f64; 4] {
let same = copy_key(content);
let copies: Vec<[f64; 4]> = items.into_iter().filter(|i| copy_key(i) == same).map(Content::rect).collect();
let taken = |rect: [f64; 4]| copies.iter().any(|c| c.iter().zip(rect).all(|(a, b)| (a - b).abs() < 0.5));
let mut rect = paste_rect(content.rect(), size, 0, None);
for steps in 1..=MAX_PASTE_STEPS {
if !taken(rect) {
break;
}
rect = paste_rect(content.rect(), size, steps, None);
}
rect
}
/// What makes two items copies of each other for [`paste_place`]: all but the box, and for an
/// image all but the object holding its picture, which a paste into another document stores anew.
fn copy_key(c: &Content) -> Content {
match c.with_rect([0.0; 4]) {
Content::Image(img) => Content::Image(AddedImage { image: ObjRef::new(0, 0), ..img }),
text => text,
}
}
/// The widest explicit line before wrapping, measured with the PDF writing fonts.
pub fn text_width(t: &AddedText) -> f64 {
t.text.split('\n').map(|line| shaped_width(t, line, unicode_bidi::get_base_direction(line) == unicode_bidi::Direction::Rtl)).fold(0.0, f64::max)
}
+301
View File
@@ -0,0 +1,301 @@
use super::draw::{draw, params};
use super::layout::{first_undrawable, is_thai, nums, shaped_arabic, thai_error};
use super::types::{Added, AddedImage, AddedText, Align, CHAR_SPACING_LIMIT, Family, MarkerScan, TAG};
use super::*;
fn parse(doc: &Document, d: &Dict) -> Option<Content> {
let r = nums(doc, d.get(b"Rect"));
let rect: [f64; 4] = r.try_into().ok()?;
match d.name(b"Kind")? {
b"Text" => {
let (family, bold, italic) = Family::from_base(d.name(b"Font").unwrap_or(b"Helvetica"));
let c = nums(doc, d.get(b"Color"));
Some(Content::Text(AddedText {
rect,
text: d.get(b"Text").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default(),
family,
bold,
italic,
size: d.get(b"Size").and_then(Object::as_f64).unwrap_or(12.0),
color: if c.len() == 3 { [c[0], c[1], c[2]] } else { [0.0; 3] },
align: match d.int(b"Align") {
Some(1) => Align::Center,
Some(2) => Align::Right,
Some(3) => Align::Justify,
_ => Align::Left,
},
// Items written before character spacing have none.
char_spacing: d.get(b"CharSpacing").and_then(Object::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0),
}))
}
b"Image" => {
let crop = nums(doc, d.get(b"Crop"));
Some(Content::Image(AddedImage {
rect,
image: d.get(b"Image")?.as_ref()?,
rotation: (d.int(b"Rotate").unwrap_or(0).rem_euclid(360) / 90) as u8,
flip_h: matches!(d.get(b"FlipH"), Some(Object::Bool(true))),
flip_v: matches!(d.get(b"FlipV"), Some(Object::Bool(true))),
crop: crop.try_into().unwrap_or([0.0; 4]),
}))
}
_ => None,
}
}
fn with_resolved<T>(doc: &Document, object: &Object, f: impl FnOnce(&Object) -> T) -> T {
match object {
Object::Ref(reference) => {
let resolved = doc.get(*reference);
f(&resolved)
}
direct => f(direct),
}
}
fn for_each_content_entry(doc: &Document, page: &pdfcraft_model::Page, mut visit: impl FnMut(&Object)) {
let Some(contents) = page.dict.get(b"Contents") else { return };
with_resolved(doc, contents, |resolved| match resolved {
Object::Array(entries) => {
for entry in entries {
visit(entry);
}
}
_ => visit(contents),
});
}
/// Finds page marks and editable added content in one pass over the page content entries.
pub fn scan_markers(doc: &Document) -> MarkerScan {
let mut scan = MarkerScan::default();
for (pi, page) in page_list(doc).iter().enumerate() {
for_each_content_entry(doc, page, |entry| {
with_resolved(doc, entry, |resolved| {
let Some(dict) = resolved.as_dict() else { return };
let tag = dict.name(b"PCMark");
let kind = if tag == Some(&b"HeaderFooter"[..]) {
Some(MarkKind::HeaderFooter)
} else if tag == Some(&b"Watermark"[..]) {
Some(MarkKind::Watermark)
} else if tag == Some(&b"Background"[..]) {
Some(MarkKind::Background)
} else {
None
};
if let Some(kind) = kind
&& !scan.marks.contains(&kind)
{
scan.marks.push(kind);
}
let Some(obj) = Object::as_ref(entry) else { return };
if tag != Some(TAG.as_bytes()) {
return;
}
let Some(params_object) = dict.get(b"PCAdded") else { return };
with_resolved(doc, params_object, |resolved_params| {
let Some(params) = resolved_params.as_dict() else { return };
if let Some(content) = parse(doc, params) {
// Display points depend on the page's /UserUnit. An item records the one it was
// written for; none means 1, as for items written before display space followed
// /UserUnit.
let written = params
.get(b"UserUnit")
.and_then(Object::as_f64)
.filter(|u| u.is_finite() && *u >= 1.0)
.map_or(1.0, |u| u.min(pdfcraft_model::MAX_USER_UNIT));
scan.added.push(Added { page: pi, obj, content: content.scaled(page.user_unit(doc) / written) });
}
});
});
});
}
scan
}
fn validate(c: &Content) -> Result<(), EditError> {
let r = c.rect();
if !r.iter().all(|v| v.is_finite()) {
return Err(EditError::Invalid("invalid position".into()));
}
match c {
Content::Text(t) => {
if t.text.trim().is_empty() {
return Err(EditError::Invalid("type some text first".into()));
}
if !(t.size.is_finite() && (1.0..=500.0).contains(&t.size)) {
return Err(EditError::Invalid("the font size must be between 1 and 500 points".into()));
}
if !(t.char_spacing.is_finite() && (-CHAR_SPACING_LIMIT..=CHAR_SPACING_LIMIT).contains(&t.char_spacing)) {
return Err(EditError::Invalid(format!(
"the character spacing must be between -{CHAR_SPACING_LIMIT} and {CHAR_SPACING_LIMIT} points"
)));
}
if (r[2] - r[0]).abs() < 1.0 {
return Err(EditError::Invalid("the text box is too narrow".into()));
}
if t.text.chars().any(is_thai) && pdfcraft_fonts::document_thai_font().is_none() {
return Err(thai_error(GlyphError::NoFont, &t.text));
}
// Without the Arabic face, `draw` decides for the Arabic letters themselves (#403): a new
// item is refused with what's missing, and one that already holds Arabic stays movable.
// Everything else in it is drawn in the standard font, so it still has to fit.
let undrawable = if t.text.chars().any(shaped_arabic) && pdfcraft_fonts::document_arabic_font().is_none() {
t.text.split('\n').find_map(|line| {
line.chars()
.find(|c| !(shaped_arabic(*c) || is_thai(*c) && pdfcraft_fonts::thai_has(*c) || pdfcraft_fonts::win_ansi_encodable(*c)))
})
} else {
first_undrawable(t)
};
if let Some(c) = undrawable {
return Err(crate::undrawable(c));
}
}
Content::Image(_) => {
if (r[2] - r[0]).abs() < 1.0 || (r[3] - r[1]).abs() < 1.0 {
return Err(EditError::Invalid("the image is too small".into()));
}
}
}
Ok(())
}
/// Write an item's stream (new, or replacing `obj`) and make its resources available to the page.
fn write(doc: &mut Document, page: usize, c: &Content, obj: Option<ObjRef>) -> Result<ObjRef, EditError> {
validate(c)?;
let all = page_list(doc);
check(&[page], all.len())?;
let p = &all[page];
let view = p.view_matrix(doc);
let user_unit = p.user_unit(doc);
// The page gets its own copy of its resources with the item's fonts and images added, and
// without the Arabic fonts of the version it replaces.
let mut pres = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
if let Some(r) = obj {
drop_fonts(doc, &mut pres, &own_fonts(doc, r));
}
let taken = pres.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
let (content, res) = draw(doc, c, view, &taken, obj.is_some())?;
if obj.is_none() {
crate::check_on_top(doc, p)?;
}
for (k, v) in res.iter() {
let mut sub = pres.get(k).map(|s| doc.resolve(s)).and_then(|s| s.as_dict().cloned()).unwrap_or_default();
for (n2, o) in v.as_dict().into_iter().flat_map(|d| d.iter()) {
sub.set(n2.clone(), o.clone());
}
pres.set(k.clone(), Object::Dict(sub));
}
doc.update_dict(p.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(pres)))?;
let mut sd = Dict::new();
sd.set(b"PCMark".to_vec(), Object::name(TAG));
let mut added = params(c);
if user_unit != 1.0 {
added.set(b"UserUnit".to_vec(), Object::Real(user_unit));
}
sd.set(b"PCAdded".to_vec(), Object::Dict(added));
let stream = Stream::flate(sd, &content);
match obj {
Some(r) => {
doc.set(r, Object::Stream(stream));
Ok(r)
}
None => {
// place_tagged appends a new stream; recover its reference.
let p = page_list(doc).swap_remove(page);
place_tagged(doc, &p, TAG, content.clone(), false)?;
let p = page_list(doc).swap_remove(page);
let r = contents(doc, &p)?.last().and_then(Object::as_ref).ok_or_else(|| EditError::Invalid("could not add the content".into()))?;
doc.set(r, Object::Stream(stream));
Ok(r)
}
}
}
/// Every added item, page by page, in drawing order.
pub fn list_added(doc: &Document) -> Vec<Added> {
scan_markers(doc).added
}
/// Add an item to a page (0-based). Returns its index among the page's added items.
pub fn add_content(doc: &mut Document, page: usize, c: &Content) -> Result<usize, EditError> {
write(doc, page, c, None)?;
Ok(list_added(doc).iter().filter(|a| a.page == page).count() - 1)
}
fn find(doc: &Document, page: usize, index: usize) -> Result<Added, EditError> {
list_added(doc)
.into_iter()
.filter(|a| a.page == page)
.nth(index)
.ok_or_else(|| EditError::Invalid(format!("page {} has no added item {}", page + 1, index + 1)))
}
/// Replace item `index` of `page` (moved, resized, retyped or reformatted).
pub fn update_content(doc: &mut Document, page: usize, index: usize, c: &Content) -> Result<(), EditError> {
let a = find(doc, page, index)?;
if std::mem::discriminant(&a.content) != std::mem::discriminant(c) {
return Err(EditError::Invalid("an item can't change between text and image".into()));
}
write(doc, page, c, Some(a.obj))?;
Ok(())
}
/// Remove item `index` of `page`.
pub fn delete_content(doc: &mut Document, page: usize, index: usize) -> Result<(), EditError> {
let a = find(doc, page, index)?;
let p = page_list(doc).swap_remove(page);
let list: Vec<Object> = contents(doc, &p)?.into_iter().filter(|o| o.as_ref() != Some(a.obj)).collect();
let fonts = own_fonts(doc, a.obj);
// Only the page's own resources carry an item's fonts (`write` gives the page a copy).
let res = match p.dict.get(b"Resources") {
Some(Object::Dict(d)) if !fonts.is_empty() => Some(d.clone()),
_ => None,
};
let res = res.map(|mut r| {
drop_fonts(doc, &mut r, &fonts);
r
});
doc.update_dict(p.obj, |d| {
d.set(b"Contents".to_vec(), Object::Array(list));
if let Some(r) = res {
d.set(b"Resources".to_vec(), Object::Dict(r));
}
})?;
Ok(())
}
/// Leave only item `index` on `page`: its contents become the item's own stream and its
/// annotations go, so a render of the page (on a transparent backdrop) is the item's pixels
/// alone. For previews on a copy of the document; the page's resources stay as they are.
pub fn isolate_content(doc: &mut Document, page: usize, index: usize) -> Result<(), EditError> {
let a = find(doc, page, index)?;
let p = page_list(doc).swap_remove(page);
// An item's stream starts from the page's initial graphics state: `place_tagged` closes
// everything drawn before it, so it draws the same on its own.
doc.update_dict(p.obj, |d| {
d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(a.obj)]));
d.remove(b"Annots");
})?;
Ok(())
}
/// The Type 3 fonts an Arabic item's stream shows text with. Each item has its own (`draw_shaped`
/// names them so), so they go when the item is replaced or deleted.
fn own_fonts(doc: &Document, obj: ObjRef) -> Vec<Vec<u8>> {
let Object::Stream(s) = &*doc.get(obj) else { return Vec::new() };
let Ok(data) = s.decoded() else { return Vec::new() };
data.split(u8::is_ascii_whitespace).filter_map(|w| w.strip_prefix(b"/")).filter(|w| w.starts_with(b"PCAr")).map(<[u8]>::to_vec).collect()
}
fn drop_fonts(doc: &Document, res: &mut Dict, names: &[Vec<u8>]) {
if names.is_empty() {
return;
}
let Some(mut fonts) = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()) else { return };
for name in names {
fonts.remove(name);
}
res.set(b"Font".to_vec(), Object::Dict(fonts));
}
+212
View File
@@ -0,0 +1,212 @@
use super::layout::norm;
use super::*;
pub(super) const TAG: &str = "Added";
/// The most character spacing an added text takes either way, in points: as far as the font size goes.
pub const CHAR_SPACING_LIMIT: f64 = 500.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Family {
#[default]
Helvetica,
Times,
Courier,
}
impl Family {
pub fn label(self) -> &'static str {
match self {
Family::Helvetica => "Helvetica",
Family::Times => "Times",
Family::Courier => "Courier",
}
}
/// The standard-14 font name for this family and style.
pub fn base_font(self, bold: bool, italic: bool) -> &'static str {
match (self, bold, italic) {
(Family::Helvetica, false, false) => "Helvetica",
(Family::Helvetica, true, false) => "Helvetica-Bold",
(Family::Helvetica, false, true) => "Helvetica-Oblique",
(Family::Helvetica, true, true) => "Helvetica-BoldOblique",
(Family::Times, false, false) => "Times-Roman",
(Family::Times, true, false) => "Times-Bold",
(Family::Times, false, true) => "Times-Italic",
(Family::Times, true, true) => "Times-BoldItalic",
(Family::Courier, false, false) => "Courier",
(Family::Courier, true, false) => "Courier-Bold",
(Family::Courier, false, true) => "Courier-Oblique",
(Family::Courier, true, true) => "Courier-BoldOblique",
}
}
pub(super) fn from_base(base: &[u8]) -> (Family, bool, bool) {
let s = String::from_utf8_lossy(base);
let family = if s.starts_with("Times") {
Family::Times
} else if s.starts_with("Courier") {
Family::Courier
} else {
Family::Helvetica
};
(family, s.contains("Bold"), s.contains("Italic") || s.contains("Oblique"))
}
/// Advance width of `s` at `size` points, from the published standard-14 metrics.
///
/// `italic` matters: the item is written out as the oblique/italic face (see [`Family::base_font`]),
/// whose published widths differ from the upright face (Times-Italic `A` is 611, Times-Roman 722).
pub fn width(self, s: &str, size: f64, bold: bool, italic: bool) -> f64 {
self.std14(bold, italic).text_width(s, size)
}
/// The standard-14 face this family means at the given style.
pub fn std14(self, bold: bool, italic: bool) -> Std14 {
match (self, bold, italic) {
(Family::Courier, false, false) => Std14::Courier,
(Family::Courier, true, false) => Std14::CourierBold,
(Family::Courier, false, true) => Std14::CourierOblique,
(Family::Courier, true, true) => Std14::CourierBoldOblique,
(Family::Times, false, false) => Std14::TimesRoman,
(Family::Times, true, false) => Std14::TimesBold,
(Family::Times, false, true) => Std14::TimesItalic,
(Family::Times, true, true) => Std14::TimesBoldItalic,
(Family::Helvetica, false, false) => Std14::Helvetica,
(Family::Helvetica, true, false) => Std14::HelveticaBold,
(Family::Helvetica, false, true) => Std14::HelveticaOblique,
(Family::Helvetica, true, true) => Std14::HelveticaBoldOblique,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Align {
#[default]
Left,
Center,
Right,
/// Lines (but the last) stretched to the box's width.
Justify,
}
#[derive(Clone, Debug, PartialEq)]
pub struct AddedText {
/// The text box in display space; its width wraps the text and its top is the first line's
/// top. The height follows the text.
pub rect: [f64; 4],
pub text: String,
pub family: Family,
pub bold: bool,
pub italic: bool,
pub size: f64,
pub color: [f64; 3],
pub align: Align,
/// Extra space after every character, in points: the PDF's character spacing (`Tc`).
pub char_spacing: f64,
}
impl Default for AddedText {
fn default() -> Self {
AddedText {
rect: [0.0; 4],
text: String::new(),
family: Family::Helvetica,
bold: false,
italic: false,
size: 12.0,
color: [0.0; 3],
align: Align::Left,
char_spacing: 0.0,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct AddedImage {
/// Display space.
pub rect: [f64; 4],
/// The image XObject.
pub image: ObjRef,
/// Quarter turns counter-clockwise (0–3).
pub rotation: u8,
pub flip_h: bool,
pub flip_v: bool,
/// The fraction trimmed from each side of the image: left, bottom, right, top (see
/// [`AddedImage::crop_valid`]).
pub crop: [f64; 4],
}
impl AddedImage {
pub fn new(rect: [f64; 4], image: ObjRef) -> Self {
AddedImage { rect, image, rotation: 0, flip_h: false, flip_v: false, crop: [0.0; 4] }
}
/// The least of the image's width and of its height that a crop keeps.
pub const MIN_CROP_KEEP: f64 = 0.05;
/// How far a sum of two sides may pass the most a crop trims: `0.05 + 0.9` is
/// `0.9500000000000001` in floating point and still keeps 5 %.
const CROP_ROUNDING: f64 = 1e-9;
/// Whether `crop` trims every side by a fraction from 0 to under 1 and keeps at least
/// [`AddedImage::MIN_CROP_KEEP`] across each axis: one side may trim 95 % when its opposite
/// trims nothing.
pub fn crop_valid(crop: [f64; 4]) -> bool {
let [l, b, r, t] = crop;
let most = 1.0 - Self::MIN_CROP_KEEP;
crop.iter().all(|v| v.is_finite() && (0.0..1.0).contains(v)) && l + r <= most + Self::CROP_ROUNDING && b + t <= most + Self::CROP_ROUNDING
}
/// `crop` brought into [`AddedImage::crop_valid`] range, for one read from a file: each side
/// in range, and an axis that trims too much keeps its two sides' proportion.
pub fn clamp_crop(crop: [f64; 4]) -> [f64; 4] {
let most = 1.0 - Self::MIN_CROP_KEEP;
let [l, b, r, t] = crop.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 });
// Scaled from the halves, whose sum can't overflow, so the pair keeps its proportion;
// a pair within the limit keeps each side within it too.
let fit = |a: f64, c: f64| {
let half = a / 2.0 + c / 2.0;
if half > (most + Self::CROP_ROUNDING) / 2.0 { (a / 2.0 / half * most, c / 2.0 / half * most) } else { (a, c) }
};
let ((l, r), (b, t)) = (fit(l, r), fit(b, t));
[l, b, r, t]
}
/// The matrix from image space (the unit square) to display space: crop, flip, rotate,
/// then fill the box.
pub(super) fn matrix(&self) -> pdfcraft_content::Matrix {
use pdfcraft_content::Matrix as M;
let [l, b, r, t] = Self::clamp_crop(self.crop);
let (cw, ch) = (1.0 - l - r, 1.0 - b - t);
let mut m = M([1.0 / cw, 0.0, 0.0, 1.0 / ch, -l / cw, -b / ch]);
if self.flip_h {
m = m.then(&M([-1.0, 0.0, 0.0, 1.0, 1.0, 0.0]));
}
if self.flip_v {
m = m.then(&M([1.0, 0.0, 0.0, -1.0, 0.0, 1.0]));
}
for _ in 0..self.rotation % 4 {
// A quarter turn counter-clockwise within the unit square.
m = m.then(&M([0.0, 1.0, -1.0, 0.0, 1.0, 0.0]));
}
let [x0, y0, x1, y1] = norm(self.rect);
m.then(&M([x1 - x0, 0.0, 0.0, y1 - y0, x0, y0]))
}
}
/// An added item on a page.
#[derive(Clone, Debug, PartialEq)]
pub struct Added {
pub page: usize,
/// The item's content stream.
pub obj: ObjRef,
pub content: Content,
}
/// PdfCraft-owned page content discovered in one page/content traversal.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct MarkerScan {
pub marks: Vec<MarkKind>,
pub added: Vec<Added>,
}