mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-01 05:58:12 -06:00
* M7: a moved text box's drag preview shows the box alone (#4464) While an added text box or picture was dragged, the pixels that followed the pointer were cut from the page raster inside the item's box, so whatever the box covered at its first place (a filled shape, a picture, other text) was carried along with it. The drag now renders the item alone too: a copy of the document whose page keeps only the item's own content stream and no annotations, drawn on a transparent page (`pdfcraft_edit::isolate_content`, `Document::added_content_alone`). Those pixels follow the pointer over the page rendered without the item, so what it covered stays put. The preview shows once both rasters are in; until then the item stays where it is, as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC * M7: drop the move preview's earlier rasters when a re-render fails (#4464) When the scale changed after both the page-without-item and item-alone rasters were in, and a render at the new scale failed, only the renderers were cleared. The old textures stayed, so the item kept following the pointer from them although the preview had given up. A failure now clears both texture slots too, so the item stays where it is while dragged. The receive step moves into its own method so a test can drive it with a pool whose renders always fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1296 lines
56 KiB
Rust
1296 lines
56 KiB
Rust
//! Edit a PDF ▸ Add content: text and images added as page content (execution plan M7.4).
|
||
//!
|
||
//! Each added item is its own content stream on the page, tagged `/PCMark /Added`, with its
|
||
//! parameters (text, font, size, colour, alignment, box; or image and box) kept in the stream
|
||
//! dictionary under `/PCAdded`. That keeps the item editable later (move, resize, retype,
|
||
//! reformat, delete) without content-stream surgery, while every viewer sees ordinary page
|
||
//! content. Boxes are in display space (points, origin at the bottom-left of the page as shown,
|
||
//! after `/UserUnit` and `/Rotate`), so items stay upright on rotated pages; an item records the
|
||
//! `/UserUnit` it was written for.
|
||
|
||
use std::collections::{HashMap, HashSet};
|
||
|
||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
|
||
use pdfcraft_fonts::{
|
||
GlyphError, GlyphOutline, ShapedCluster, ShapedScript, Std14, literal, shape_arabic, shape_hebrew, shape_thai, shaped_glyph, win_ansi,
|
||
};
|
||
use unicode_bidi::{Level, ParagraphBidiInfo};
|
||
|
||
use crate::{EditError, MarkKind, check, contents, matrix_number, n, page_list, place_tagged};
|
||
|
||
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",
|
||
}
|
||
}
|
||
|
||
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]))
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub enum Content {
|
||
Text(AddedText),
|
||
Image(AddedImage),
|
||
}
|
||
|
||
impl Content {
|
||
pub fn rect(&self) -> [f64; 4] {
|
||
match self {
|
||
Content::Text(t) => t.rect,
|
||
Content::Image(i) => i.rect,
|
||
}
|
||
}
|
||
|
||
/// The same item with its box and text size scaled by `factor`.
|
||
fn scaled(self, factor: f64) -> Content {
|
||
match self {
|
||
_ if factor == 1.0 => self,
|
||
Content::Text(t) => {
|
||
Content::Text(AddedText { rect: t.rect.map(|v| v * factor), size: t.size * factor, char_spacing: t.char_spacing * factor, ..t })
|
||
}
|
||
Content::Image(i) => Content::Image(AddedImage { rect: i.rect.map(|v| v * factor), ..i }),
|
||
}
|
||
}
|
||
|
||
/// The same item moved/resized to `rect` (text keeps its computed height).
|
||
pub fn with_rect(&self, rect: [f64; 4]) -> Content {
|
||
match self {
|
||
Content::Text(t) => Content::Text(AddedText { rect, ..t.clone() }),
|
||
Content::Image(i) => Content::Image(AddedImage { rect, ..i.clone() }),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// An added item on a page.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct Added {
|
||
pub page: usize,
|
||
/// The item's content stream.
|
||
pub obj: ObjRef,
|
||
pub content: Content,
|
||
}
|
||
|
||
/// PdfCraft-owned page content discovered in one page/content traversal.
|
||
#[derive(Clone, Debug, Default, PartialEq)]
|
||
pub struct MarkerScan {
|
||
pub marks: Vec<MarkKind>,
|
||
pub added: Vec<Added>,
|
||
}
|
||
|
||
fn nums(doc: &Document, o: Option<&Object>) -> Vec<f64> {
|
||
o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default()
|
||
}
|
||
|
||
fn norm(r: [f64; 4]) -> [f64; 4] {
|
||
[r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]
|
||
}
|
||
|
||
/// The 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<String> {
|
||
wrapped(t).into_iter().map(|(line, _)| line).collect()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
thread_local! {
|
||
/// How often [`wrapped`] laid a text out on this thread, for the test that keeps it out of per-line loops.
|
||
pub(crate) static WRAP_CALLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||
}
|
||
|
||
/// [`lines`], each with whether its paragraph runs right to left (its first strong character
|
||
/// does).
|
||
fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
|
||
#[cfg(test)]
|
||
WRAP_CALLS.with(|c| c.set(c.get().saturating_add(1)));
|
||
let width = (t.rect[2] - t.rect[0]).max(t.size);
|
||
let mut out = Vec::new();
|
||
for para in t.text.split('\n') {
|
||
// Arabic or Hebrew: laid out with the bidirectional algorithm and shaped.
|
||
let rtl_script = para.chars().any(|c| is_arabic(c) || is_hebrew(c));
|
||
let thai = para.chars().any(is_thai);
|
||
if thai
|
||
&& !rtl_script
|
||
&& let Ok(lines) = thai_lines(t, para, width)
|
||
{
|
||
out.extend(lines.into_iter().map(|line| (line, false)));
|
||
continue;
|
||
}
|
||
let rtl = rtl_script && unicode_bidi::get_base_direction(para) == unicode_bidi::Direction::Rtl;
|
||
// Arabic words are shaped one by one (joining stops at spaces), so a line's width is the
|
||
// sum of its words' and shaping stays linear in the paragraph's length. The space is the
|
||
// paragraph's; a space between Latin words is drawn in the item's font, a few hundredths
|
||
// of an em apart.
|
||
let measure = |s: &str| if rtl_script || thai { shaped_width(t, s, rtl) } else { latin_width(t, s) };
|
||
let space = measure(" ");
|
||
let (mut line, mut line_w) = (String::new(), 0.0);
|
||
for word in para.split(' ') {
|
||
let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
|
||
let candidate_w =
|
||
if rtl_script || thai { if line.is_empty() { measure(word) } else { line_w + space + measure(word) } } else { measure(&candidate) };
|
||
if !line.is_empty() && candidate_w > width {
|
||
out.push((std::mem::take(&mut line), rtl));
|
||
line = word.to_string();
|
||
line_w = measure(word);
|
||
} else {
|
||
line = candidate;
|
||
line_w = candidate_w;
|
||
}
|
||
}
|
||
out.push((line, rtl));
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Thai wrapping at shaped cluster boundaries never detaches a tone/vowel mark from its base.
|
||
/// This is glyph-safe wrapping, not dictionary-based Thai word segmentation.
|
||
fn thai_lines(t: &AddedText, para: &str, width: f64) -> Result<Vec<String>, EditError> {
|
||
let (pieces, _) = shaped_layout(t, para, false)?;
|
||
let mut atoms = Vec::new();
|
||
for piece in pieces {
|
||
match piece {
|
||
Piece::Shaped(clusters) => atoms.extend(clusters.into_iter().map(|c| (c.text, c.advance * t.size + t.char_spacing))),
|
||
Piece::Latin(s) => atoms.extend(s.chars().map(|c| {
|
||
let text = c.to_string();
|
||
let w = latin_width(t, &text);
|
||
(text, w)
|
||
})),
|
||
}
|
||
}
|
||
let mut lines = Vec::new();
|
||
let (mut line, mut used) = (String::new(), 0.0);
|
||
for (text, advance) in atoms {
|
||
if !line.is_empty() && used + advance > width {
|
||
lines.push(std::mem::take(&mut line));
|
||
used = 0.0;
|
||
}
|
||
line.push_str(&text);
|
||
used += advance;
|
||
}
|
||
lines.push(line);
|
||
Ok(lines)
|
||
}
|
||
|
||
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<char> {
|
||
if t.text.chars().any(is_thai) {
|
||
if let Some(c) = t.text.chars().find(|c| is_thai(*c) && !pdfcraft_fonts::thai_has(*c)) {
|
||
return Some(c);
|
||
}
|
||
let rest = AddedText { text: t.text.chars().filter(|c| !is_thai(*c)).collect(), ..t.clone() };
|
||
return first_undrawable(&rest);
|
||
}
|
||
// Arabic or Hebrew that nothing can draw: there's no face, or the face lacks it.
|
||
if let Some(c) =
|
||
t.text.chars().find(|c| (shaped_arabic(*c) && !pdfcraft_fonts::arabic_has(*c)) || (is_hebrew(*c) && !pdfcraft_fonts::hebrew_has(*c)))
|
||
{
|
||
return Some(c);
|
||
}
|
||
if !shaped_by_a_face(t) {
|
||
// Drawn line by line in the item's standard font (`\n` splits lines). Without the face
|
||
// that is also how `draw` redraws an existing item, so it's checked that way.
|
||
return t.text.split('\n').find_map(pdfcraft_fonts::first_non_win_ansi);
|
||
}
|
||
// What `shaped_layout` leaves to the standard font, exactly as `draw_shaped` splits it.
|
||
wrapped(t).into_iter().find_map(|(line, rtl)| {
|
||
let (pieces, _) = shaped_layout(t, &line, rtl).ok()?;
|
||
pieces.iter().find_map(|p| match p {
|
||
Piece::Latin(s) => pdfcraft_fonts::first_non_win_ansi(s),
|
||
Piece::Shaped(_) => None,
|
||
})
|
||
})
|
||
}
|
||
|
||
/// Whether `t` has Arabic or Hebrew that its craft-fonts face draws (so `draw` lays it out with
|
||
/// [`draw_shaped`]).
|
||
fn shaped_by_a_face(t: &AddedText) -> bool {
|
||
(pdfcraft_fonts::document_arabic_font().is_some() && t.text.chars().any(is_arabic))
|
||
|| (pdfcraft_fonts::document_hebrew_font(false).is_some() && t.text.chars().any(is_hebrew))
|
||
}
|
||
|
||
/// An Arabic character the face shapes and draws (U+061C, a direction mark, is dropped).
|
||
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<ShapedCluster>),
|
||
}
|
||
|
||
/// A line of a shaped-text item in drawing order (the Unicode bidirectional algorithm, UAX #9), and
|
||
/// its width at the item's size. `rtl` is its paragraph's direction.
|
||
fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec<Piece>, f64), EditError> {
|
||
// unicode-bidi indexes the first level of a line, so an empty one would panic.
|
||
if line.is_empty() {
|
||
return Ok((Vec::new(), 0.0));
|
||
}
|
||
let base = if rtl { Level::rtl() } else { Level::ltr() };
|
||
let info = ParagraphBidiInfo::new(line, Some(base));
|
||
let (levels, runs) = info.visual_runs(0..line.len());
|
||
let mut pieces = Vec::new();
|
||
for run in runs {
|
||
let run_rtl = levels.get(run.start).is_some_and(Level::is_rtl);
|
||
// Runs lie on character boundaries; a missing slice is skipped, never a panic.
|
||
let Some(text) = line.get(run) else { continue };
|
||
// Arabic and Hebrew letters take their face; so do spaces and punctuation between them
|
||
// (Hebrew's when they follow Hebrew), which the shaper then mirrors. Latin text (and what
|
||
// the faces lack) keeps the item's font.
|
||
let mut segments: Vec<(Option<ShapedScript>, String)> = Vec::new();
|
||
for c in text.chars().filter(|c| !is_bidi_control(*c)) {
|
||
let after_hebrew = segments.last().is_some_and(|(kind, _)| *kind == Some(ShapedScript::Hebrew));
|
||
let script = if is_thai(c) {
|
||
Some(ShapedScript::Thai)
|
||
} else if is_hebrew(c) || (run_rtl && after_hebrew && !c.is_alphanumeric() && pdfcraft_fonts::hebrew_has(c)) {
|
||
Some(ShapedScript::Hebrew)
|
||
} else if is_arabic(c) || (run_rtl && !c.is_alphanumeric() && pdfcraft_fonts::arabic_has(c)) {
|
||
Some(ShapedScript::Arabic)
|
||
} else {
|
||
None
|
||
};
|
||
match segments.last_mut() {
|
||
Some((kind, s)) if *kind == script => s.push(c),
|
||
_ => segments.push((script, c.to_string())),
|
||
}
|
||
}
|
||
if run_rtl {
|
||
segments.reverse();
|
||
}
|
||
for (script, s) in segments {
|
||
pieces.push(if let Some(script) = script {
|
||
let shaped = match script {
|
||
ShapedScript::Arabic => shape_arabic(&s, run_rtl).map_err(|e| arabic_error(e, &s))?,
|
||
ShapedScript::Hebrew => shape_hebrew(&s, run_rtl).map_err(|e| hebrew_error(e, &s))?,
|
||
ShapedScript::Thai => shape_thai(&s).map_err(|e| thai_error(e, &s))?,
|
||
};
|
||
Piece::Shaped(shaped)
|
||
} else if run_rtl {
|
||
Piece::Latin(s.chars().rev().map(mirrored).collect())
|
||
} else {
|
||
Piece::Latin(s)
|
||
});
|
||
}
|
||
}
|
||
let width = pieces
|
||
.iter()
|
||
.map(|p| match p {
|
||
Piece::Latin(s) => latin_width(t, s),
|
||
// Each cluster is shown on its own, so the spacing follows each once.
|
||
Piece::Shaped(clusters) => clusters.iter().map(|c| c.advance * t.size + t.char_spacing).sum::<f64>(),
|
||
})
|
||
.sum();
|
||
Ok((pieces, width))
|
||
}
|
||
|
||
/// The width of `s` in a shaped-text item; the item's font's estimate if it can't be shaped (drawing
|
||
/// then reports why).
|
||
fn shaped_width(t: &AddedText, s: &str, rtl: bool) -> f64 {
|
||
shaped_layout(t, s, rtl).map(|(_, w)| w).unwrap_or_else(|_| latin_width(t, s))
|
||
}
|
||
|
||
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<Item = &'a Content>, content: &Content, size: [f64; 2]) -> [f64; 4] {
|
||
let same = copy_key(content);
|
||
let copies: Vec<[f64; 4]> = items.into_iter().filter(|i| copy_key(i) == same).map(Content::rect).collect();
|
||
let taken = |rect: [f64; 4]| copies.iter().any(|c| c.iter().zip(rect).all(|(a, b)| (a - b).abs() < 0.5));
|
||
let mut rect = paste_rect(content.rect(), size, 0, None);
|
||
for steps in 1..=MAX_PASTE_STEPS {
|
||
if !taken(rect) {
|
||
break;
|
||
}
|
||
rect = paste_rect(content.rect(), size, steps, None);
|
||
}
|
||
rect
|
||
}
|
||
|
||
/// What makes two items copies of each other for [`paste_place`]: all but the box, and for an
|
||
/// image all but the object holding its picture, which a paste into another document stores anew.
|
||
fn copy_key(c: &Content) -> Content {
|
||
match c.with_rect([0.0; 4]) {
|
||
Content::Image(img) => Content::Image(AddedImage { image: ObjRef::new(0, 0), ..img }),
|
||
text => text,
|
||
}
|
||
}
|
||
|
||
/// The widest explicit line before wrapping, measured with the PDF writing fonts.
|
||
pub fn text_width(t: &AddedText) -> f64 {
|
||
t.text.split('\n').map(|line| shaped_width(t, line, unicode_bidi::get_base_direction(line) == unicode_bidi::Direction::Rtl)).fold(0.0, f64::max)
|
||
}
|
||
|
||
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<Piece>, f64);
|
||
|
||
/// Draw a text item that has Arabic, Hebrew or Thai in it. Each line is laid out in display order; their glyphs
|
||
/// come from Type 3 fonts drawn from the craft-fonts face's outlines (no font file is embedded),
|
||
/// with a ToUnicode map for copy and search. Other text uses the item's standard font.
|
||
fn draw_shaped(doc: &mut Document, t: &AddedText, taken: &Dict, fonts: &mut Dict, out: &mut Vec<u8>) -> Result<(), EditError> {
|
||
let laid = wrapped(t)
|
||
.into_iter()
|
||
.map(|(line, rtl)| shaped_layout(t, &line, rtl).map(|(pieces, w)| (line, rtl, pieces, w)))
|
||
.collect::<Result<Vec<_>, _>>()?;
|
||
draw_laid_shaped(doc, t, &laid, taken, fonts, out, shaped_glyph)
|
||
}
|
||
|
||
/// [`draw_shaped`] for lines that are already laid out. `outline` is the source of glyph outlines
|
||
/// for a script's face (the craft-fonts faces, or a stand-in in tests).
|
||
fn draw_laid_shaped(
|
||
doc: &mut Document,
|
||
t: &AddedText,
|
||
laid: &[LaidLine],
|
||
taken: &Dict,
|
||
fonts: &mut Dict,
|
||
out: &mut Vec<u8>,
|
||
outline: impl Fn(ShapedScript, u32) -> Result<GlyphOutline, GlyphError>,
|
||
) -> Result<(), EditError> {
|
||
let r = text_rect(t);
|
||
let latin = font_name(t.family.base_font(t.bold, t.italic));
|
||
// Every distinct cluster (a letter with its dots and the text it stands for) is one glyph of
|
||
// one of the fonts, so copy and search get each character exactly once.
|
||
let mut keys: Vec<&ShapedCluster> = Vec::new();
|
||
let mut index: HashMap<ClusterKey, usize> = HashMap::new();
|
||
for c in laid.iter().flat_map(|(_, _, pieces, _)| pieces).flat_map(|p| match p {
|
||
Piece::Shaped(clusters) => clusters.as_slice(),
|
||
Piece::Latin(_) => &[],
|
||
}) {
|
||
index.entry(cluster_key(c)).or_insert_with(|| {
|
||
keys.push(c);
|
||
keys.len() - 1
|
||
});
|
||
}
|
||
|
||
// Every outline is read before anything is written, so a failure leaves the document as it was.
|
||
let mut outlines = HashMap::new();
|
||
for c in &keys {
|
||
for (id, _) in &c.glyphs {
|
||
if let std::collections::hash_map::Entry::Vacant(entry) = outlines.entry((c.script, *id)) {
|
||
entry.insert(outline(c.script, *id).map_err(|e| match c.script {
|
||
ShapedScript::Arabic => arabic_error(e, &c.text),
|
||
ShapedScript::Hebrew => hebrew_error(e, &c.text),
|
||
ShapedScript::Thai => thai_error(e, &c.text),
|
||
})?);
|
||
}
|
||
}
|
||
}
|
||
// The names the page and this item already use: a new font never takes one of them.
|
||
let mut taken_names: HashSet<Vec<u8>> = taken.iter().chain(fonts.iter()).map(|(name, _)| name.clone()).collect();
|
||
let mut names = Vec::new();
|
||
for chunk in keys.chunks(GLYPHS_PER_FONT) {
|
||
let font = type3_shaped(doc, chunk, &outlines);
|
||
// A name no font on the page has, so items never replace each other's fonts (object
|
||
// numbers alone don't do: a full save renumbers objects but keeps resource names).
|
||
let mut name = format!("PCAr{}", font.num);
|
||
let mut suffix = 0usize;
|
||
while taken_names.contains(name.as_bytes()) {
|
||
suffix = suffix.saturating_add(1);
|
||
name = format!("PCAr{}_{suffix}", font.num);
|
||
}
|
||
taken_names.insert(name.clone().into_bytes());
|
||
fonts.set(name.clone().into_bytes(), Object::Ref(font));
|
||
names.push(name);
|
||
}
|
||
let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0));
|
||
out.extend(format!("BT {} {} {} rg\n", n(cr), n(cg), n(cb)).bytes());
|
||
if t.char_spacing != 0.0 {
|
||
out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes());
|
||
}
|
||
for (i, (line, rtl, pieces, w)) in laid.iter().enumerate() {
|
||
// Shaped glyphs are painted in visual order. Keep the original line's typing order
|
||
// explicitly, including marks, ligatures, digits and mixed-script punctuation.
|
||
let logical: String = line.encode_utf16().map(|u| format!("{u:04X}")).collect();
|
||
out.extend(format!("/Span <</ActualText <FEFF{logical}>>> BDC\n").bytes());
|
||
let spaces = line.matches(' ').count();
|
||
// Justified: the spaces of every line but the last stretch to fill the box.
|
||
let tw = if t.align == Align::Justify && i + 1 < laid.len() && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 };
|
||
let x = match t.align {
|
||
// A right-to-left paragraph's unstretched last line ends at the right edge.
|
||
Align::Justify if *rtl => r[2] - w - tw * spaces as f64,
|
||
Align::Left | Align::Justify => r[0],
|
||
Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0,
|
||
Align::Right => r[2] - w,
|
||
};
|
||
let y = r[3] - (i as f64 * 1.2 + 0.95) * t.size;
|
||
let mut pen = x;
|
||
let mut current: Option<&str> = None;
|
||
for piece in pieces {
|
||
match piece {
|
||
Piece::Latin(s) => {
|
||
out.extend(format!("/{latin} {} Tf {} Tw 1 0 0 1 {} {} Tm ", n(t.size), n(tw), n(pen), n(y)).bytes());
|
||
out.extend(literal(&win_ansi(s)));
|
||
out.extend_from_slice(b" Tj\n");
|
||
current = Some(latin.as_str());
|
||
pen += latin_width(t, s) + tw * s.matches(' ').count() as f64;
|
||
}
|
||
Piece::Shaped(clusters) => {
|
||
for c in clusters {
|
||
let Some(&k) = index.get(&cluster_key(c)) else { continue };
|
||
let Some(name) = names.get(k / GLYPHS_PER_FONT) else { continue };
|
||
// Codes start at 1; k % 240 + 1 is at most 240.
|
||
let code = u8::try_from(k % GLYPHS_PER_FONT + 1).unwrap_or(1);
|
||
if current != Some(name.as_str()) {
|
||
out.extend(format!("/{name} {} Tf ", n(t.size)).bytes());
|
||
current = Some(name.as_str());
|
||
}
|
||
out.extend(format!("1 0 0 1 {} {} Tm ", n(pen), n(y)).bytes());
|
||
out.extend(literal(&[code]));
|
||
out.extend_from_slice(b" Tj\n");
|
||
pen += c.advance * t.size + t.char_spacing + if c.text == " " { tw } else { 0.0 };
|
||
}
|
||
}
|
||
}
|
||
}
|
||
out.extend_from_slice(b"EMC\n");
|
||
}
|
||
out.extend_from_slice(b"ET\n");
|
||
Ok(())
|
||
}
|
||
|
||
/// What makes two clusters the same Type 3 glyph: text, glyphs, offsets and advance (bit for bit;
|
||
/// equal clusters come from the same font units).
|
||
type ClusterKey<'a> = (ShapedScript, &'a str, Vec<(u32, u64, u64)>, u64);
|
||
|
||
fn cluster_key(c: &ShapedCluster) -> ClusterKey<'_> {
|
||
(c.script, c.text.as_str(), c.glyphs.iter().map(|(id, [x, y])| (*id, x.to_bits(), y.to_bits())).collect(), c.advance.to_bits())
|
||
}
|
||
|
||
/// A Type 3 font with `clusters` as codes 1…, each drawn from its script face's glyph `outlines`.
|
||
fn type3_shaped(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &HashMap<(ShapedScript, u32), GlyphOutline>) -> ObjRef {
|
||
let mut charprocs = Dict::new();
|
||
let mut differences = vec![Object::Int(1)];
|
||
let mut widths = Vec::with_capacity(clusters.len());
|
||
let mut bbox = [0.0f64; 4];
|
||
let mut to_unicode = Vec::new();
|
||
for (i, c) in clusters.iter().enumerate() {
|
||
let code = i + 1;
|
||
// Glyph space is 1000 units per em.
|
||
let mut paths = Vec::new();
|
||
let mut ink: Option<[f64; 4]> = None;
|
||
for (id, [dx, dy]) in &c.glyphs {
|
||
let Some(g) = outlines.get(&(c.script, *id)) else { continue };
|
||
for contour in &g.contours {
|
||
for (j, p) in contour.iter().enumerate() {
|
||
paths.extend(format!("{} {} {}\n", n((p[0] + dx) * 1000.0), n((p[1] + dy) * 1000.0), if j == 0 { "m" } else { "l" }).bytes());
|
||
}
|
||
paths.extend_from_slice(b"h\n");
|
||
let b = [g.bbox[0] + dx, g.bbox[1] + dy, g.bbox[2] + dx, g.bbox[3] + dy];
|
||
ink = Some(ink.map_or(b, |a| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]));
|
||
}
|
||
}
|
||
// Rounded outwards so no ink is clipped.
|
||
let [x0, y0, x1, y1] =
|
||
ink.map_or([0.0; 4], |b| [(b[0] * 1000.0).floor(), (b[1] * 1000.0).floor(), (b[2] * 1000.0).ceil(), (b[3] * 1000.0).ceil()]);
|
||
bbox = [bbox[0].min(x0), bbox[1].min(y0), bbox[2].max(x1), bbox[3].max(y1)];
|
||
let mut proc = format!("{} 0 {} {} {} {} d1\n", n(c.advance * 1000.0), n(x0), n(y0), n(x1), n(y1)).into_bytes();
|
||
if !paths.is_empty() {
|
||
proc.extend(paths);
|
||
proc.extend_from_slice(b"f\n");
|
||
}
|
||
let glyph = format!("g{code:02X}");
|
||
charprocs.set(glyph.clone().into_bytes(), Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &proc)))));
|
||
differences.push(Object::name(&glyph));
|
||
widths.push(Object::Real((c.advance * 1000.0).round()));
|
||
if !c.text.is_empty() {
|
||
let hex: String = c.text.encode_utf16().map(|u| format!("{u:04X}")).collect();
|
||
to_unicode.push(format!("<{code:02X}> <{hex}>\n"));
|
||
}
|
||
}
|
||
let mut cmap = String::from(
|
||
"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CMapType 2 def\n1 begincodespacerange\n<01> <FF>\nendcodespacerange\n",
|
||
);
|
||
// A CMap block holds at most 100 entries.
|
||
for block in to_unicode.chunks(100) {
|
||
cmap.push_str(&format!("{} beginbfchar\n{}endbfchar\n", block.len(), block.concat()));
|
||
}
|
||
cmap.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n");
|
||
let cmap = doc.add(Object::Stream(Stream::flate(Dict::new(), cmap.as_bytes())));
|
||
// The face's family when every glyph comes from one script; without the face, the script's
|
||
// name (as Arabic items were always named).
|
||
let family = match clusters.first().map(|c| c.script).filter(|s| clusters.iter().all(|c| c.script == *s)) {
|
||
Some(script) => script.font().map_or(
|
||
match script {
|
||
ShapedScript::Arabic => "Arabic",
|
||
ShapedScript::Hebrew => "Hebrew",
|
||
ShapedScript::Thai => "Thai",
|
||
},
|
||
|f| f.family,
|
||
),
|
||
None => "PdfCraft Shaped Text",
|
||
};
|
||
let mut descriptor = Dict::new();
|
||
descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor"));
|
||
descriptor.set(b"FontName".to_vec(), Object::name(&family.replace(' ', "")));
|
||
descriptor.set(b"FontFamily".to_vec(), Object::String(PdfString::literal(family.as_bytes().to_vec())));
|
||
descriptor.set(b"Flags".to_vec(), Object::Int(4));
|
||
descriptor.set(b"ItalicAngle".to_vec(), Object::Int(0));
|
||
let mut encoding = Dict::new();
|
||
encoding.set(b"Type".to_vec(), Object::name("Encoding"));
|
||
encoding.set(b"Differences".to_vec(), Object::Array(differences));
|
||
let mut font = Dict::new();
|
||
font.set(b"Type".to_vec(), Object::name("Font"));
|
||
font.set(b"Subtype".to_vec(), Object::name("Type3"));
|
||
font.set(b"FontDescriptor".to_vec(), Object::Ref(doc.add(Object::Dict(descriptor))));
|
||
font.set(b"FontBBox".to_vec(), Object::Array(bbox.iter().map(|v| Object::Real(*v)).collect()));
|
||
font.set(
|
||
b"FontMatrix".to_vec(),
|
||
Object::Array(vec![Object::Real(0.001), Object::Int(0), Object::Int(0), Object::Real(0.001), Object::Int(0), Object::Int(0)]),
|
||
);
|
||
font.set(b"FirstChar".to_vec(), Object::Int(1));
|
||
font.set(b"LastChar".to_vec(), Object::Int(clusters.len() as i64));
|
||
font.set(b"Widths".to_vec(), Object::Array(widths));
|
||
font.set(b"Encoding".to_vec(), Object::Dict(encoding));
|
||
font.set(b"CharProcs".to_vec(), Object::Dict(charprocs));
|
||
font.set(b"ToUnicode".to_vec(), Object::Ref(cmap));
|
||
doc.add(Object::Dict(font))
|
||
}
|
||
|
||
/// Content and resources for an item; `view` maps display space to user space. `taken` holds the
|
||
/// page's font names. `existing`: the item is being replaced, so a build without the Arabic or
|
||
/// Hebrew face keeps drawing that text as `?`, as before it was supported, rather than refusing to
|
||
/// move it.
|
||
fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing: bool) -> Result<(Vec<u8>, Dict), EditError> {
|
||
let mut res = Dict::new();
|
||
let mut out = format!(
|
||
"q {} {} {} {} {} {} cm\n",
|
||
matrix_number(view[0]),
|
||
matrix_number(view[1]),
|
||
matrix_number(view[2]),
|
||
matrix_number(view[3]),
|
||
matrix_number(view[4]),
|
||
matrix_number(view[5])
|
||
)
|
||
.into_bytes();
|
||
match c {
|
||
Content::Text(t) => {
|
||
let base = t.family.base_font(t.bold, t.italic);
|
||
let name = font_name(base);
|
||
let mut font = Dict::new();
|
||
font.set(b"Type".to_vec(), Object::name("Font"));
|
||
font.set(b"Subtype".to_vec(), Object::name("Type1"));
|
||
font.set(b"BaseFont".to_vec(), Object::name(base));
|
||
if t.family != Family::Courier || !t.text.is_ascii() {
|
||
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
|
||
}
|
||
let mut fonts = Dict::new();
|
||
fonts.set(name.clone().into_bytes(), Object::Dict(font));
|
||
let face_or_new = |has: fn(char) -> bool, face: bool| t.text.chars().any(has) && (face || !existing);
|
||
if t.text.chars().any(is_thai)
|
||
|| face_or_new(is_arabic, pdfcraft_fonts::document_arabic_font().is_some())
|
||
|| face_or_new(is_hebrew, pdfcraft_fonts::document_hebrew_font(false).is_some())
|
||
{
|
||
draw_shaped(doc, t, taken, &mut fonts, &mut out)?;
|
||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||
out.extend_from_slice(b"Q\n");
|
||
return Ok((out, res));
|
||
}
|
||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||
let r = text_rect(t);
|
||
let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0));
|
||
out.extend(format!("BT /{name} {} Tf {} {} {} rg\n", n(t.size), n(cr), n(cg), n(cb)).bytes());
|
||
if t.char_spacing != 0.0 {
|
||
out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes());
|
||
}
|
||
let all = lines(t);
|
||
for (i, line) in all.iter().enumerate() {
|
||
let w = latin_width(t, line);
|
||
let x = match t.align {
|
||
Align::Left | Align::Justify => r[0],
|
||
Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0,
|
||
Align::Right => r[2] - w,
|
||
};
|
||
// Baseline: 0.8 em below the line top.
|
||
let y = r[3] - (i as f64 * 1.2 + 0.95) * t.size;
|
||
// Justified: word spacing makes every line but the last fill the box.
|
||
let spaces = line.matches(' ').count();
|
||
let tw =
|
||
if t.align == Align::Justify && i + 1 < all.len() && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 };
|
||
out.extend(format!("1 0 0 1 {} {} Tm {} Tw ", n(x), n(y), n(tw)).bytes());
|
||
out.extend(literal(&win_ansi(line)));
|
||
out.extend_from_slice(b" Tj\n");
|
||
}
|
||
out.extend_from_slice(b"ET\n");
|
||
}
|
||
Content::Image(i) => {
|
||
if !matches!(&*doc.get(i.image), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image")) {
|
||
return Err(EditError::Invalid("not an image".into()));
|
||
}
|
||
let r = norm(i.rect);
|
||
let name = format!("PCImg{}", i.image.num);
|
||
let mut xo = Dict::new();
|
||
xo.set(name.clone().into_bytes(), Object::Ref(i.image));
|
||
res.set(b"XObject".to_vec(), Object::Dict(xo));
|
||
let [a, b, c, d, e, f] = i.matrix().0;
|
||
// Clip to the box: the cropped-away parts stay hidden.
|
||
out.extend(
|
||
format!(
|
||
"{} {} {} {} re W n {} {} {} {} {} {} cm /{name} Do\n",
|
||
n(r[0]),
|
||
n(r[1]),
|
||
n(r[2] - r[0]),
|
||
n(r[3] - r[1]),
|
||
n(a),
|
||
n(b),
|
||
n(c),
|
||
n(d),
|
||
n(e),
|
||
n(f)
|
||
)
|
||
.bytes(),
|
||
);
|
||
}
|
||
}
|
||
out.extend_from_slice(b"Q\n");
|
||
Ok((out, res))
|
||
}
|
||
|
||
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<Content> {
|
||
let r = nums(doc, d.get(b"Rect"));
|
||
let rect: [f64; 4] = r.try_into().ok()?;
|
||
match d.name(b"Kind")? {
|
||
b"Text" => {
|
||
let (family, bold, italic) = Family::from_base(d.name(b"Font").unwrap_or(b"Helvetica"));
|
||
let c = nums(doc, d.get(b"Color"));
|
||
Some(Content::Text(AddedText {
|
||
rect,
|
||
text: d.get(b"Text").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default(),
|
||
family,
|
||
bold,
|
||
italic,
|
||
size: d.get(b"Size").and_then(Object::as_f64).unwrap_or(12.0),
|
||
color: if c.len() == 3 { [c[0], c[1], c[2]] } else { [0.0; 3] },
|
||
align: match d.int(b"Align") {
|
||
Some(1) => Align::Center,
|
||
Some(2) => Align::Right,
|
||
Some(3) => Align::Justify,
|
||
_ => Align::Left,
|
||
},
|
||
// Items written before character spacing have none.
|
||
char_spacing: d.get(b"CharSpacing").and_then(Object::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0),
|
||
}))
|
||
}
|
||
b"Image" => {
|
||
let crop = nums(doc, d.get(b"Crop"));
|
||
Some(Content::Image(AddedImage {
|
||
rect,
|
||
image: d.get(b"Image")?.as_ref()?,
|
||
rotation: (d.int(b"Rotate").unwrap_or(0).rem_euclid(360) / 90) as u8,
|
||
flip_h: matches!(d.get(b"FlipH"), Some(Object::Bool(true))),
|
||
flip_v: matches!(d.get(b"FlipV"), Some(Object::Bool(true))),
|
||
crop: crop.try_into().unwrap_or([0.0; 4]),
|
||
}))
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn with_resolved<T>(doc: &Document, object: &Object, f: impl FnOnce(&Object) -> T) -> T {
|
||
match object {
|
||
Object::Ref(reference) => {
|
||
let resolved = doc.get(*reference);
|
||
f(&resolved)
|
||
}
|
||
direct => f(direct),
|
||
}
|
||
}
|
||
|
||
fn for_each_content_entry(doc: &Document, page: &pdfcraft_model::Page, mut visit: impl FnMut(&Object)) {
|
||
let Some(contents) = page.dict.get(b"Contents") else { return };
|
||
with_resolved(doc, contents, |resolved| match resolved {
|
||
Object::Array(entries) => {
|
||
for entry in entries {
|
||
visit(entry);
|
||
}
|
||
}
|
||
_ => visit(contents),
|
||
});
|
||
}
|
||
|
||
/// Finds page marks and editable added content in one pass over the page content entries.
|
||
pub fn scan_markers(doc: &Document) -> MarkerScan {
|
||
let mut scan = MarkerScan::default();
|
||
for (pi, page) in page_list(doc).iter().enumerate() {
|
||
for_each_content_entry(doc, page, |entry| {
|
||
with_resolved(doc, entry, |resolved| {
|
||
let Some(dict) = resolved.as_dict() else { return };
|
||
let tag = dict.name(b"PCMark");
|
||
let kind = if tag == Some(&b"HeaderFooter"[..]) {
|
||
Some(MarkKind::HeaderFooter)
|
||
} else if tag == Some(&b"Watermark"[..]) {
|
||
Some(MarkKind::Watermark)
|
||
} else if tag == Some(&b"Background"[..]) {
|
||
Some(MarkKind::Background)
|
||
} else {
|
||
None
|
||
};
|
||
if let Some(kind) = kind
|
||
&& !scan.marks.contains(&kind)
|
||
{
|
||
scan.marks.push(kind);
|
||
}
|
||
|
||
let Some(obj) = Object::as_ref(entry) else { return };
|
||
if tag != Some(TAG.as_bytes()) {
|
||
return;
|
||
}
|
||
let Some(params_object) = dict.get(b"PCAdded") else { return };
|
||
with_resolved(doc, params_object, |resolved_params| {
|
||
let Some(params) = resolved_params.as_dict() else { return };
|
||
if let Some(content) = parse(doc, params) {
|
||
// Display points depend on the page's /UserUnit. An item records the one it was
|
||
// written for; none means 1, as for items written before display space followed
|
||
// /UserUnit.
|
||
let written = params
|
||
.get(b"UserUnit")
|
||
.and_then(Object::as_f64)
|
||
.filter(|u| u.is_finite() && *u >= 1.0)
|
||
.map_or(1.0, |u| u.min(pdfcraft_model::MAX_USER_UNIT));
|
||
scan.added.push(Added { page: pi, obj, content: content.scaled(page.user_unit(doc) / written) });
|
||
}
|
||
});
|
||
});
|
||
});
|
||
}
|
||
scan
|
||
}
|
||
|
||
fn validate(c: &Content) -> Result<(), EditError> {
|
||
let r = c.rect();
|
||
if !r.iter().all(|v| v.is_finite()) {
|
||
return Err(EditError::Invalid("invalid position".into()));
|
||
}
|
||
match c {
|
||
Content::Text(t) => {
|
||
if t.text.trim().is_empty() {
|
||
return Err(EditError::Invalid("type some text first".into()));
|
||
}
|
||
if !(t.size.is_finite() && (1.0..=500.0).contains(&t.size)) {
|
||
return Err(EditError::Invalid("the font size must be between 1 and 500 points".into()));
|
||
}
|
||
if !(t.char_spacing.is_finite() && (-CHAR_SPACING_LIMIT..=CHAR_SPACING_LIMIT).contains(&t.char_spacing)) {
|
||
return Err(EditError::Invalid(format!(
|
||
"the character spacing must be between -{CHAR_SPACING_LIMIT} and {CHAR_SPACING_LIMIT} points"
|
||
)));
|
||
}
|
||
if (r[2] - r[0]).abs() < 1.0 {
|
||
return Err(EditError::Invalid("the text box is too narrow".into()));
|
||
}
|
||
if t.text.chars().any(is_thai) && pdfcraft_fonts::document_thai_font().is_none() {
|
||
return Err(thai_error(GlyphError::NoFont, &t.text));
|
||
}
|
||
// Without the Arabic face, `draw` decides for the Arabic letters themselves (#403): a new
|
||
// item is refused with what's missing, and one that already holds Arabic stays movable.
|
||
// Everything else in it is drawn in the standard font, so it still has to fit.
|
||
let undrawable = if t.text.chars().any(shaped_arabic) && pdfcraft_fonts::document_arabic_font().is_none() {
|
||
t.text.split('\n').find_map(|line| {
|
||
line.chars()
|
||
.find(|c| !(shaped_arabic(*c) || is_thai(*c) && pdfcraft_fonts::thai_has(*c) || pdfcraft_fonts::win_ansi_encodable(*c)))
|
||
})
|
||
} else {
|
||
first_undrawable(t)
|
||
};
|
||
if let Some(c) = undrawable {
|
||
return Err(crate::undrawable(c));
|
||
}
|
||
}
|
||
Content::Image(_) => {
|
||
if (r[2] - r[0]).abs() < 1.0 || (r[3] - r[1]).abs() < 1.0 {
|
||
return Err(EditError::Invalid("the image is too small".into()));
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Write an item's stream (new, or replacing `obj`) and make its resources available to the page.
|
||
fn write(doc: &mut Document, page: usize, c: &Content, obj: Option<ObjRef>) -> Result<ObjRef, EditError> {
|
||
validate(c)?;
|
||
let all = page_list(doc);
|
||
check(&[page], all.len())?;
|
||
let p = &all[page];
|
||
let view = p.view_matrix(doc);
|
||
let user_unit = p.user_unit(doc);
|
||
// The page gets its own copy of its resources with the item's fonts and images added, and
|
||
// without the Arabic fonts of the version it replaces.
|
||
let mut pres = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||
if let Some(r) = obj {
|
||
drop_fonts(doc, &mut pres, &own_fonts(doc, r));
|
||
}
|
||
let taken = pres.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
|
||
let (content, res) = draw(doc, c, view, &taken, obj.is_some())?;
|
||
if obj.is_none() {
|
||
crate::check_on_top(doc, p)?;
|
||
}
|
||
for (k, v) in res.iter() {
|
||
let mut sub = pres.get(k).map(|s| doc.resolve(s)).and_then(|s| s.as_dict().cloned()).unwrap_or_default();
|
||
for (n2, o) in v.as_dict().into_iter().flat_map(|d| d.iter()) {
|
||
sub.set(n2.clone(), o.clone());
|
||
}
|
||
pres.set(k.clone(), Object::Dict(sub));
|
||
}
|
||
doc.update_dict(p.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(pres)))?;
|
||
let mut sd = Dict::new();
|
||
sd.set(b"PCMark".to_vec(), Object::name(TAG));
|
||
let mut added = params(c);
|
||
if user_unit != 1.0 {
|
||
added.set(b"UserUnit".to_vec(), Object::Real(user_unit));
|
||
}
|
||
sd.set(b"PCAdded".to_vec(), Object::Dict(added));
|
||
let stream = Stream::flate(sd, &content);
|
||
match obj {
|
||
Some(r) => {
|
||
doc.set(r, Object::Stream(stream));
|
||
Ok(r)
|
||
}
|
||
None => {
|
||
// place_tagged appends a new stream; recover its reference.
|
||
let p = page_list(doc).swap_remove(page);
|
||
place_tagged(doc, &p, TAG, content.clone(), false)?;
|
||
let p = page_list(doc).swap_remove(page);
|
||
let r = contents(doc, &p)?.last().and_then(Object::as_ref).ok_or_else(|| EditError::Invalid("could not add the content".into()))?;
|
||
doc.set(r, Object::Stream(stream));
|
||
Ok(r)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Every added item, page by page, in drawing order.
|
||
pub fn list_added(doc: &Document) -> Vec<Added> {
|
||
scan_markers(doc).added
|
||
}
|
||
|
||
/// Add an item to a page (0-based). Returns its index among the page's added items.
|
||
pub fn add_content(doc: &mut Document, page: usize, c: &Content) -> Result<usize, EditError> {
|
||
write(doc, page, c, None)?;
|
||
Ok(list_added(doc).iter().filter(|a| a.page == page).count() - 1)
|
||
}
|
||
|
||
fn find(doc: &Document, page: usize, index: usize) -> Result<Added, EditError> {
|
||
list_added(doc)
|
||
.into_iter()
|
||
.filter(|a| a.page == page)
|
||
.nth(index)
|
||
.ok_or_else(|| EditError::Invalid(format!("page {} has no added item {}", page + 1, index + 1)))
|
||
}
|
||
|
||
/// Replace item `index` of `page` (moved, resized, retyped or reformatted).
|
||
pub fn update_content(doc: &mut Document, page: usize, index: usize, c: &Content) -> Result<(), EditError> {
|
||
let a = find(doc, page, index)?;
|
||
if std::mem::discriminant(&a.content) != std::mem::discriminant(c) {
|
||
return Err(EditError::Invalid("an item can't change between text and image".into()));
|
||
}
|
||
write(doc, page, c, Some(a.obj))?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Remove item `index` of `page`.
|
||
pub fn delete_content(doc: &mut Document, page: usize, index: usize) -> Result<(), EditError> {
|
||
let a = find(doc, page, index)?;
|
||
let p = page_list(doc).swap_remove(page);
|
||
let list: Vec<Object> = contents(doc, &p)?.into_iter().filter(|o| o.as_ref() != Some(a.obj)).collect();
|
||
let fonts = own_fonts(doc, a.obj);
|
||
// Only the page's own resources carry an item's fonts (`write` gives the page a copy).
|
||
let res = match p.dict.get(b"Resources") {
|
||
Some(Object::Dict(d)) if !fonts.is_empty() => Some(d.clone()),
|
||
_ => None,
|
||
};
|
||
let res = res.map(|mut r| {
|
||
drop_fonts(doc, &mut r, &fonts);
|
||
r
|
||
});
|
||
doc.update_dict(p.obj, |d| {
|
||
d.set(b"Contents".to_vec(), Object::Array(list));
|
||
if let Some(r) = res {
|
||
d.set(b"Resources".to_vec(), Object::Dict(r));
|
||
}
|
||
})?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Leave only item `index` on `page`: its contents become the item's own stream and its
|
||
/// annotations go, so a render of the page (on a transparent backdrop) is the item's pixels
|
||
/// alone. For previews on a copy of the document; the page's resources stay as they are.
|
||
pub fn isolate_content(doc: &mut Document, page: usize, index: usize) -> Result<(), EditError> {
|
||
let a = find(doc, page, index)?;
|
||
let p = page_list(doc).swap_remove(page);
|
||
// An item's stream starts from the page's initial graphics state: `place_tagged` closes
|
||
// everything drawn before it, so it draws the same on its own.
|
||
doc.update_dict(p.obj, |d| {
|
||
d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(a.obj)]));
|
||
d.remove(b"Annots");
|
||
})?;
|
||
Ok(())
|
||
}
|
||
|
||
/// The Type 3 fonts an Arabic item's stream shows text with. Each item has its own (`draw_shaped`
|
||
/// names them so), so they go when the item is replaced or deleted.
|
||
fn own_fonts(doc: &Document, obj: ObjRef) -> Vec<Vec<u8>> {
|
||
let Object::Stream(s) = &*doc.get(obj) else { return Vec::new() };
|
||
let Ok(data) = s.decoded() else { return Vec::new() };
|
||
data.split(u8::is_ascii_whitespace).filter_map(|w| w.strip_prefix(b"/")).filter(|w| w.starts_with(b"PCAr")).map(<[u8]>::to_vec).collect()
|
||
}
|
||
|
||
fn drop_fonts(doc: &Document, res: &mut Dict, names: &[Vec<u8>]) {
|
||
if names.is_empty() {
|
||
return;
|
||
}
|
||
let Some(mut fonts) = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()) else { return };
|
||
for name in names {
|
||
fonts.remove(name);
|
||
}
|
||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod arabic_tests;
|