From 36a68fc15eaed8b475797567e0df3a62a7fcc592 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:54:14 +0000 Subject: [PATCH] refactor(edit): split added.rs into added/ New modules: types, layout, draw, store. --- crates/edit/src/added.rs | 1250 +------------------------------ crates/edit/src/added/draw.rs | 383 ++++++++++ crates/edit/src/added/layout.rs | 356 +++++++++ crates/edit/src/added/store.rs | 301 ++++++++ crates/edit/src/added/types.rs | 212 ++++++ 5 files changed, 1265 insertions(+), 1237 deletions(-) create mode 100644 crates/edit/src/added/draw.rs create mode 100644 crates/edit/src/added/layout.rs create mode 100644 crates/edit/src/added/store.rs create mode 100644 crates/edit/src/added/types.rs diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs index d292c0869..40e540fb0 100644 --- a/crates/edit/src/added.rs +++ b/crates/edit/src/added.rs @@ -18,199 +18,20 @@ use unicode_bidi::{Level, ParagraphBidiInfo}; use crate::{EditError, MarkKind, check, contents, matrix_number, n, page_list, place_tagged}; -const TAG: &str = "Added"; +mod draw; +mod layout; +mod store; +mod types; -/// 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", - } - } - - 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. - 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])) - } -} +#[cfg(test)] +use draw::{GLYPHS_PER_FONT, LaidLine, draw_laid_shaped}; +#[cfg(test)] +use layout::Piece; +#[cfg(test)] +pub(crate) use layout::WRAP_CALLS; +pub use layout::{PASTE_STEP, first_undrawable, lines, paste_place, paste_rect, text_rect, text_width}; +pub use store::{add_content, delete_content, isolate_content, list_added, scan_markers, update_content}; +pub use types::{Added, AddedImage, AddedText, Align, CHAR_SPACING_LIMIT, Family, MarkerScan}; #[derive(Clone, Debug, PartialEq)] pub enum Content { @@ -246,1050 +67,5 @@ impl Content { } } -/// 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, - pub added: Vec, -} - -fn nums(doc: &Document, o: Option<&Object>) -> Vec { - 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 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). -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 { - 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 = const { std::cell::Cell::new(0) }; -} - -/// [`lines`], each with whether its paragraph runs right to left (its first strong character -/// does). -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, 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) -} - -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. -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. -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 { - 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). -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. -enum Piece { - /// Shown in the item's own standard font. - Latin(String), - Shaped(Vec), -} - -/// 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. -fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec, 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, 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::(), - }) - .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)) -} - -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"), - }) -} - -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"), - }) -} - -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, 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) -} - -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. -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. -type LaidLine = (String, bool, Vec, 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) -> Result<(), EditError> { - let laid = wrapped(t) - .into_iter() - .map(|(line, rtl)| shaped_layout(t, &line, rtl).map(|(pieces, w)| (line, rtl, pieces, w))) - .collect::, _>>()?; - 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). -fn draw_laid_shaped( - doc: &mut Document, - t: &AddedText, - laid: &[LaidLine], - taken: &Dict, - fonts: &mut Dict, - out: &mut Vec, - outline: impl Fn(ShapedScript, u32) -> Result, -) -> 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 = 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> = 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 <>> 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> \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. -fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing: bool) -> Result<(Vec, 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)) -} - -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 -} - -fn parse(doc: &Document, d: &Dict) -> Option { - 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(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) -> Result { - 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 { - 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 { - 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 { - 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 = 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> { - 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]) { - 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)); -} - #[cfg(test)] mod arabic_tests; diff --git a/crates/edit/src/added/draw.rs b/crates/edit/src/added/draw.rs new file mode 100644 index 000000000..0fc7aa510 --- /dev/null +++ b/crates/edit/src/added/draw.rs @@ -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, 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) -> Result<(), EditError> { + let laid = wrapped(t) + .into_iter() + .map(|(line, rtl)| shaped_layout(t, &line, rtl).map(|(pieces, w)| (line, rtl, pieces, w))) + .collect::, _>>()?; + 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, + outline: impl Fn(ShapedScript, u32) -> Result, +) -> 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 = 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> = 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 <>> 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> \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, 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 +} diff --git a/crates/edit/src/added/layout.rs b/crates/edit/src/added/layout.rs new file mode 100644 index 000000000..0e971b92c --- /dev/null +++ b/crates/edit/src/added/layout.rs @@ -0,0 +1,356 @@ +use super::types::{AddedImage, AddedText}; +use super::*; + +pub(super) fn nums(doc: &Document, o: Option<&Object>) -> Vec { + 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 { + 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 = 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, 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 { + 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), +} + +/// 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, 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, 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::(), + }) + .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, 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) +} diff --git a/crates/edit/src/added/store.rs b/crates/edit/src/added/store.rs new file mode 100644 index 000000000..b89d42900 --- /dev/null +++ b/crates/edit/src/added/store.rs @@ -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 { + 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(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) -> Result { + 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 { + 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 { + 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 { + 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 = 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> { + 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]) { + 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)); +} diff --git a/crates/edit/src/added/types.rs b/crates/edit/src/added/types.rs new file mode 100644 index 000000000..0a9f92f92 --- /dev/null +++ b/crates/edit/src/added/types.rs @@ -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, + pub added: Vec, +}