mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-31 05:28:09 -06:00
Add text wrote Arabic as "?" (standard-14 fonts, WinAnsi). Items with Arabic are now shaped with the craft-fonts Arab face through harfrust (already in the tree via egui) and laid out line by line in display order with unicode-bidi (UAX #9), so Arabic mixed with Latin, digits and brackets reads correctly. Each cluster (a letter with its dots or marks) is one glyph of a Type 3 font drawn from the face's outlines, with a ToUnicode entry, so text extraction, copy and search get every letter once and no font file is embedded. - fonts: shape_arabic / arabic_glyph / arabic_has / document_arabic_font; japanese_glyph's bounded outline code is shared as bounded_outline. - edit: draw_arabic and type3_arabic; wrapping measures shaped words. Fonts get names free on the page (a full save renumbers objects, not resource names) and are removed when the item is updated or deleted. Outlines are read before anything is written. - Without the face, adding Arabic is a clear error that changes nothing; an existing item holding Arabic still moves, drawn as "?" as before. - Non-Arabic items are drawn exactly as before. Refs #250, #280, #369. Co-authored-by: fares <aw674860@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
855 lines
36 KiB
Rust
855 lines
36 KiB
Rust
//! Edit a PDF ▸ Add content: text and images added as page content (execution plan M7.4).
|
||
//!
|
||
//! Each added item is its own content stream on the page, tagged `/PCMark /Added`, with its
|
||
//! parameters (text, font, size, colour, alignment, box; or image and box) kept in the stream
|
||
//! dictionary under `/PCAdded`. That keeps the item editable later (move, resize, retype,
|
||
//! reformat, delete) without content-stream surgery, while every viewer sees ordinary page
|
||
//! content. Boxes are in display space (origin at the bottom-left of the page as shown, after
|
||
//! `/Rotate`), so items stay upright on rotated pages.
|
||
|
||
use std::collections::HashMap;
|
||
|
||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
|
||
use pdfcraft_fonts::{GlyphError, GlyphOutline, ShapedCluster, arabic_glyph, helvetica_width, literal, shape_arabic, win_ansi};
|
||
use unicode_bidi::{Level, ParagraphBidiInfo};
|
||
|
||
use crate::{EditError, check, contents, n, page_list, place_tagged};
|
||
|
||
const TAG: &str = "Added";
|
||
|
||
#[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",
|
||
}
|
||
}
|
||
|
||
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"))
|
||
}
|
||
|
||
/// Approximate advance width of `s` (standard-14 metrics are not bundled: Helvetica widths,
|
||
/// scaled for Times; Courier is monospaced).
|
||
pub fn width(self, s: &str, size: f64, bold: bool) -> f64 {
|
||
let w = match self {
|
||
Family::Courier => s.chars().count() as f64 * 0.6 * size,
|
||
Family::Times => helvetica_width(s, size) * 0.9,
|
||
Family::Helvetica => helvetica_width(s, size),
|
||
};
|
||
if bold { w * 1.05 } else { w }
|
||
}
|
||
}
|
||
|
||
#[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,
|
||
}
|
||
|
||
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,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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.
|
||
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 matrix from image space (the unit square) to display space: crop, flip, rotate,
|
||
/// then fill the box.
|
||
fn matrix(&self) -> pdfcraft_content::Matrix {
|
||
use pdfcraft_content::Matrix as M;
|
||
let [l, b, r, t] = self.crop.map(|v| v.clamp(0.0, 0.45));
|
||
let (cw, ch) = ((1.0 - l - r).max(0.05), (1.0 - b - t).max(0.05));
|
||
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]))
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub enum Content {
|
||
Text(AddedText),
|
||
Image(AddedImage),
|
||
}
|
||
|
||
impl Content {
|
||
pub fn rect(&self) -> [f64; 4] {
|
||
match self {
|
||
Content::Text(t) => t.rect,
|
||
Content::Image(i) => i.rect,
|
||
}
|
||
}
|
||
|
||
/// The same item moved/resized to `rect` (text keeps its computed height).
|
||
pub fn with_rect(&self, rect: [f64; 4]) -> Content {
|
||
match self {
|
||
Content::Text(t) => Content::Text(AddedText { rect, ..t.clone() }),
|
||
Content::Image(i) => Content::Image(AddedImage { rect, ..i.clone() }),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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,
|
||
}
|
||
|
||
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()
|
||
}
|
||
|
||
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 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()
|
||
}
|
||
|
||
/// [`lines`], each with whether its paragraph runs right to left (its first strong character
|
||
/// does).
|
||
fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
|
||
let width = (t.rect[2] - t.rect[0]).max(t.size);
|
||
let mut out = Vec::new();
|
||
for para in t.text.split('\n') {
|
||
let arabic = para.chars().any(is_arabic);
|
||
let rtl = arabic && 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 arabic { arabic_width(t, s, rtl) } else { t.family.width(s, t.size, t.bold) };
|
||
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 arabic { 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
|
||
}
|
||
|
||
/// Arabic letters, marks, digits and punctuation: an item with any of them is shaped with the
|
||
/// craft-fonts Arabic face.
|
||
fn is_arabic(c: char) -> bool {
|
||
matches!(u32::from(c), 0x0600..=0x06FF | 0x0750..=0x077F | 0x0870..=0x08FF | 0xFB50..=0xFDFF | 0xFE70..=0xFEFC)
|
||
}
|
||
|
||
/// 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 Arabic text, in drawing order.
|
||
enum Piece {
|
||
/// Shown in the item's own standard font.
|
||
Latin(String),
|
||
Arabic(Vec<ShapedCluster>),
|
||
}
|
||
|
||
/// A line of an Arabic item in drawing order (the Unicode bidirectional algorithm, UAX #9), and
|
||
/// its width at the item's size. `rtl` is its paragraph's direction.
|
||
fn arabic_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 letters take the Arabic face; so do spaces and punctuation between them, which the
|
||
// shaper then mirrors. Latin text (and what the face lacks) keeps the item's font.
|
||
let mut segments: Vec<(bool, String)> = Vec::new();
|
||
for c in text.chars().filter(|c| !is_bidi_control(*c)) {
|
||
let arabic = is_arabic(c) || (run_rtl && !c.is_alphanumeric() && pdfcraft_fonts::arabic_has(c));
|
||
match segments.last_mut() {
|
||
Some((kind, s)) if *kind == arabic => s.push(c),
|
||
_ => segments.push((arabic, c.to_string())),
|
||
}
|
||
}
|
||
if run_rtl {
|
||
segments.reverse();
|
||
}
|
||
for (arabic, s) in segments {
|
||
pieces.push(if arabic {
|
||
Piece::Arabic(shape_arabic(&s, run_rtl).map_err(|e| arabic_error(e, &s))?)
|
||
} 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) => t.family.width(s, t.size, t.bold),
|
||
Piece::Arabic(clusters) => clusters.iter().map(|c| c.advance).sum::<f64>() * t.size,
|
||
})
|
||
.sum();
|
||
Ok((pieces, width))
|
||
}
|
||
|
||
/// The width of `s` in an Arabic item; the item's font's estimate if it can't be shaped (drawing
|
||
/// then reports why).
|
||
fn arabic_width(t: &AddedText, s: &str, rtl: bool) -> f64 {
|
||
arabic_layout(t, s, rtl).map(|(_, w)| w).unwrap_or_else(|_| t.family.width(s, t.size, t.bold))
|
||
}
|
||
|
||
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"),
|
||
})
|
||
}
|
||
|
||
/// 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]]
|
||
}
|
||
|
||
fn font_name(base: &str) -> String {
|
||
format!("PCF{}", base.replace('-', ""))
|
||
}
|
||
|
||
/// Most glyphs in one Type 3 font: its codes are single bytes, 1–240.
|
||
const GLYPHS_PER_FONT: usize = 240;
|
||
/// Most Type 3 fonts one item may use, which bounds the objects it adds (ordinary text needs one
|
||
/// or two).
|
||
const MAX_ARABIC_FONTS: usize = 16;
|
||
|
||
/// Draw a text item that has Arabic in it. Each line is laid out in display order; Arabic 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 `latin`.
|
||
fn draw_arabic(doc: &mut Document, t: &AddedText, latin: &str, taken: &Dict, fonts: &mut Dict, out: &mut Vec<u8>) -> Result<(), EditError> {
|
||
let r = text_rect(t);
|
||
let lines = wrapped(t);
|
||
let laid = lines.iter().map(|(line, rtl)| arabic_layout(t, line, *rtl)).collect::<Result<Vec<_>, _>>()?;
|
||
// 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::Arabic(clusters) => clusters.as_slice(),
|
||
Piece::Latin(_) => &[],
|
||
}) {
|
||
index.entry(cluster_key(c)).or_insert_with(|| {
|
||
keys.push(c);
|
||
keys.len() - 1
|
||
});
|
||
}
|
||
if keys.len() > GLYPHS_PER_FONT * MAX_ARABIC_FONTS {
|
||
return Err(EditError::Invalid("the text uses too many different Arabic glyphs; split it into several boxes".into()));
|
||
}
|
||
// 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 !outlines.contains_key(id) {
|
||
outlines.insert(*id, arabic_glyph(*id).map_err(|e| arabic_error(e, &c.text))?);
|
||
}
|
||
}
|
||
}
|
||
let mut names = Vec::new();
|
||
for chunk in keys.chunks(GLYPHS_PER_FONT) {
|
||
let font = type3_arabic(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.contains(name.as_bytes()) || fonts.contains(name.as_bytes()) {
|
||
suffix = suffix.saturating_add(1);
|
||
name = format!("PCAr{}_{suffix}", font.num);
|
||
}
|
||
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());
|
||
for (i, ((line, rtl), (pieces, w))) in lines.iter().zip(&laid).enumerate() {
|
||
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 < lines.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);
|
||
pen += t.family.width(s, t.size, t.bold) + tw * s.matches(' ').count() as f64;
|
||
}
|
||
Piece::Arabic(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 + if c.text == " " { tw } else { 0.0 };
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
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> = (&'a str, Vec<(u32, u64, u64)>, u64);
|
||
|
||
fn cluster_key(c: &ShapedCluster) -> ClusterKey<'_> {
|
||
(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 the Arabic face's glyph `outlines`.
|
||
fn type3_arabic(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &HashMap<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(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())));
|
||
let family = pdfcraft_fonts::document_arabic_font().map_or("Arabic", |f| f.family);
|
||
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 face
|
||
/// keeps drawing its Arabic as `?`, as before Arabic was supported, rather than refusing to move it.
|
||
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", n(view[0]), n(view[1]), n(view[2]), n(view[3]), n(view[4]), n(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));
|
||
if t.text.chars().any(is_arabic) && (pdfcraft_fonts::document_arabic_font().is_some() || !existing) {
|
||
draw_arabic(doc, t, &name, 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());
|
||
for (i, line) in lines(t).iter().enumerate() {
|
||
let w = t.family.width(line, t.size, t.bold);
|
||
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 all = lines(t);
|
||
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))
|
||
}
|
||
|
||
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)));
|
||
}
|
||
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
|
||
}
|
||
|
||
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,
|
||
},
|
||
}))
|
||
}
|
||
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 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 (r[2] - r[0]).abs() < 1.0 {
|
||
return Err(EditError::Invalid("the text box is too narrow".into()));
|
||
}
|
||
}
|
||
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);
|
||
// 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())?;
|
||
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));
|
||
sd.set(b"PCAdded".to_vec(), Object::Dict(params(c)));
|
||
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> {
|
||
let mut out = Vec::new();
|
||
for (pi, p) in page_list(doc).iter().enumerate() {
|
||
let list: Vec<Object> = match p.dict.get(b"Contents") {
|
||
Some(c) => match &*doc.resolve(c) {
|
||
Object::Array(a) => a.clone(),
|
||
_ => vec![c.clone()],
|
||
},
|
||
None => Vec::new(),
|
||
};
|
||
for o in list {
|
||
let Some(r) = o.as_ref() else { continue };
|
||
let obj = doc.get(r);
|
||
let Some(d) = obj.as_dict() else { continue };
|
||
if d.name(b"PCMark") != Some(TAG.as_bytes()) {
|
||
continue;
|
||
}
|
||
if let Some(c) = d.get(b"PCAdded").and_then(|p| doc.resolve(p).as_dict().cloned()).and_then(|p| parse(doc, &p)) {
|
||
out.push(Added { page: pi, obj: r, content: c });
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// 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(())
|
||
}
|
||
|
||
/// The Type 3 fonts an Arabic item's stream shows text with. Each item has its own (`draw_arabic`
|
||
/// 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));
|
||
}
|