mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
Fix text editing layout and preserve source PDF font styles (#36)
* Comments: a text box's rectangle follows its edited text Editing a text box (or typewriter or callout) rewrote /Contents but kept the old /Rect: the appearance clipped the new text to the old box and silently dropped lines that no longer fit, while shorter text left an oversized box. set_contents now re-measures the text with the same Helvetica widths as at creation and re-fits the box: the wrap width and top edge stay, the height fits the wrapped lines. A callout's leader line keeps its place and re-attaches to the moved box (that geometry moved from set_rect into a shared apply_text_box). A locked box keeps its text editable, so it re-fits too. Covered by annot tests (grow and shrink with nothing clipped, callout leader re-attach, locked box) and a comment_edit round trip through the automation tools; parity entries cite them. Co-Authored-By: GLM <[email protected]> * Polish edit text and images tool * feat(edit-text): select source font style * fix(edit-text): preserve detected PDF font styles * feat(edit-text): support Japanese replacements * fix(forms): escape non-ASCII format script strings * Forms: unwrap_or instead of map_or with identity (clippy map_or_identity) Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Brandon Thomas <[email protected]> Co-authored-by: GLM <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
19 files changed
+1060
-93
No files matched your search
+3
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
Every asset PrintCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
|
||||
|
||||
## In this repository (242)
|
||||
## In this repository (244)
|
||||
|
||||
| Asset | Title | Author | Licence | Source | Used for |
|
||||
|---|---|---|---|---|---|
|
||||
@@ -186,6 +186,8 @@ Every asset PrintCraft includes, bundles or uses to build its published material
|
||||
| `assets/fonts/Inter-Medium.ttf` | Inter Medium | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels |
|
||||
| `assets/fonts/Inter-SemiBold.ttf` | Inter SemiBold | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels |
|
||||
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono Regular | The JetBrains Mono Project Authors | OFL-1.1 | https://github.com/JetBrains/JetBrainsMono | UI monospace font; demo PDF code |
|
||||
| `assets/fonts/ShipporiMincho-Regular.ttf` | Shippori Mincho Regular | The Shippori Mincho Project Authors (FONTDASU) | OFL-1.1 | https://raw.githubusercontent.com/google/fonts/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/shipporimincho/ShipporiMincho-Regular.ttf | Japanese fallback glyphs in edited PDF text and the edit-text UI |
|
||||
| `assets/fonts/OFL-ShipporiMincho.txt` | SIL Open Font License 1.1 for Shippori Mincho | The Shippori Mincho Project Authors (FONTDASU) | OFL-1.1 | https://raw.githubusercontent.com/google/fonts/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/shipporimincho/OFL.txt | License for Shippori Mincho fallback font |
|
||||
| `vendor/hayro-interpret/assets/FoxitDingbats.pfb` | FoxitDingbats (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) |
|
||||
| `vendor/hayro-interpret/assets/FoxitFixed.pfb` | FoxitFixed (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) |
|
||||
| `vendor/hayro-interpret/assets/FoxitFixedBold.pfb` | FoxitFixedBold (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) |
|
||||
|
||||
@@ -1966,6 +1966,28 @@ kind = "font"
|
||||
usage = "UI monospace font; demo PDF code"
|
||||
sha256 = "e6fd0d7e91550b3ed2b735d4312474362c4716edc4fc0577a0f61ed782d5aed1"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/fonts/ShipporiMincho-Regular.ttf"
|
||||
title = "Shippori Mincho Regular"
|
||||
author = "The Shippori Mincho Project Authors (FONTDASU)"
|
||||
source = "https://raw.githubusercontent.com/google/fonts/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/shipporimincho/ShipporiMincho-Regular.ttf"
|
||||
licence = "OFL-1.1"
|
||||
licence_file = "assets/fonts/OFL-ShipporiMincho.txt"
|
||||
kind = "font"
|
||||
usage = "Japanese fallback glyphs in edited PDF text and the edit-text UI"
|
||||
sha256 = "769b5269f0f9bc6534b352c0e6bd856a566e03ff788f107191c2d835863570b2"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/fonts/OFL-ShipporiMincho.txt"
|
||||
title = "SIL Open Font License 1.1 for Shippori Mincho"
|
||||
author = "The Shippori Mincho Project Authors (FONTDASU)"
|
||||
source = "https://raw.githubusercontent.com/google/fonts/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/shipporimincho/OFL.txt"
|
||||
licence = "OFL-1.1"
|
||||
licence_file = "assets/fonts/OFL-ShipporiMincho.txt"
|
||||
kind = "licence"
|
||||
usage = "License for Shippori Mincho fallback font"
|
||||
sha256 = "41fba056279be5f45ff9a99e44b7b53897b42732f5806d8e666e0ab49ac6bd38"
|
||||
|
||||
[[asset]]
|
||||
path = "vendor/hayro-interpret/assets/FoxitDingbats.pfb"
|
||||
title = "FoxitDingbats (standard-14 substitute font)"
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2021 The Shippori Mincho Project Authors (https://github.com/fontdasu/ShipporiMincho)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
+45
-2
@@ -1150,7 +1150,8 @@ pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Resul
|
||||
set_annots(doc, p, kept)
|
||||
}
|
||||
|
||||
/// Change a comment's text. Text boxes are redrawn to show it.
|
||||
/// Change a comment's text. Text boxes are redrawn to show it, and their rectangle follows the
|
||||
/// new text: the wrap width and top edge stay, the height fits the wrapped lines.
|
||||
pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, meta: &Meta) -> Result<(), AnnotError> {
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
let free_text = annot_dict(doc, r).name(b"Subtype") == Some(b"FreeText");
|
||||
@@ -1165,11 +1166,46 @@ pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, m
|
||||
touch(d, meta);
|
||||
})?;
|
||||
if free_text {
|
||||
set_appearance(doc, r)?;
|
||||
// The box grows and shrinks with the text instead of clipping it. A locked box keeps its
|
||||
// text editable (as in Acrobat), so the re-fit ignores the lock.
|
||||
match fitted_box(doc, r, text) {
|
||||
Some(rect) => apply_text_box(doc, r, rect, meta)?,
|
||||
None => set_appearance(doc, r)?,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The rectangle a FreeText annotation's text box needs for `text`: the current wrap width and
|
||||
/// top edge stay and the height fits the wrapped lines, as at creation. `None` keeps the old
|
||||
/// rectangle (the box can't be measured, so only the appearance is redrawn).
|
||||
fn fitted_box(doc: &Document, r: ObjRef, text: &str) -> Option<[f64; 4]> {
|
||||
let d = annot_dict(doc, r);
|
||||
let nums = |key: &[u8]| -> Option<Vec<f64>> { d.get(key)?.as_array()?.iter().map(|o| o.as_f64()).collect() };
|
||||
let (_, size) = appearance::parse_da(&d);
|
||||
let pad = 2.0 + border_width_of(&d);
|
||||
let rect = nums(b"Rect").filter(|v| v.len() == 4 && v.iter().all(|x| x.is_finite()))?;
|
||||
let rect = [rect[0], rect[1], rect[2], rect[3]];
|
||||
// A callout re-fits its text box (`/Rect` inset by `/RD`); the leader line keeps its place.
|
||||
let tb = if d.contains(b"CL") {
|
||||
let rd = nums(b"RD").filter(|v| v.len() == 4 && v.iter().all(|x| *x >= 0.0))?;
|
||||
[rect[0] + rd[0], rect[1] + rd[1], rect[2] - rd[2], rect[3] - rd[3]]
|
||||
} else {
|
||||
rect
|
||||
};
|
||||
let (x0, top) = (tb[0].min(tb[2]), tb[1].max(tb[3]));
|
||||
let w = tb[2] - tb[0];
|
||||
let w = if w.is_finite() && w >= 1.0 {
|
||||
w
|
||||
} else {
|
||||
// No usable old width: fall back to the creation-time width (at most 300 pt).
|
||||
let longest = text.lines().map(|l| appearance::text_width(l, size)).fold(0.0, f64::max);
|
||||
(longest + 2.0 * pad + 4.0).clamp(40.0, 300.0)
|
||||
};
|
||||
let lines = appearance::wrap(text, size, (w - 2.0 * pad).max(1.0)).len().max(1) as f64;
|
||||
Some([x0, top - lines * size * 1.2 - 2.0 * pad - 2.0, x0 + w, top])
|
||||
}
|
||||
|
||||
/// Reply to a comment; returns the reply's index in the page's `/Annots`.
|
||||
///
|
||||
/// A reply is a text annotation with `/IRT` pointing at its parent (§12.5.6.2). It gets the
|
||||
@@ -1326,10 +1362,17 @@ pub fn set_rect(doc: &mut Document, page: usize, index: usize, rect: [f64; 4], m
|
||||
if !matches!(subtype.as_str(), "Square" | "Circle" | "FreeText") {
|
||||
return Err(AnnotError::Invalid(format!("{subtype} comments can't be resized")));
|
||||
}
|
||||
apply_text_box(doc, r, rect, meta)
|
||||
}
|
||||
|
||||
/// Store `rect` as a FreeText annotation's text box (or a square's/oval's rectangle): a callout's
|
||||
/// leader line re-attaches and `/Rect` grows to hold it, and the appearance is redrawn.
|
||||
fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> {
|
||||
let rect = normalize(rect);
|
||||
if !finite(&rect) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
|
||||
return Err(AnnotError::Invalid("invalid rectangle (too small)".into()));
|
||||
}
|
||||
let d = annot_dict(doc, r);
|
||||
// A callout's rectangle is its text box: the leader line re-attaches and `/Rect` grows to hold it.
|
||||
let callout = d.get(b"CL").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect::<Vec<f64>>()).filter(|l| l.len() == 6);
|
||||
let (outer, cl) = match callout {
|
||||
|
||||
@@ -279,6 +279,80 @@ fn text_box_text_and_colour_changes_are_drawn() {
|
||||
assert!(rc.starts_with("<?xml") && rc.contains("<p dir=\"ltr\">Caf\u{e9} (draft) \u{2014} 100%</p>") && rc.contains("color:#FF0000"), "{rc}");
|
||||
}
|
||||
|
||||
/// The rectangle a text box needs for `text` (the appearance's padding plus the 2 pt slack):
|
||||
/// the wrap width and top edge stay, the height fits the wrapped lines.
|
||||
fn expected_box(top: f64, w: f64, text: &str, size: f64, border: f64) -> [f64; 4] {
|
||||
let pad = 2.0 + border;
|
||||
let lines = appearance::wrap(text, size, w - 2.0 * pad).len().max(1) as f64;
|
||||
[0.0, top - lines * size * 1.2 - 2.0 * pad - 2.0, w, 0.0]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_a_text_box_refits_its_rectangle() {
|
||||
let mut doc = fixture();
|
||||
let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [100.0, 680.0, 304.0, 692.0], font_size: 12.0 }), &meta("t")).unwrap();
|
||||
// The style's border is 2 pt wide, so the text is padded by 2 + 2.
|
||||
let long = "the quick brown fox jumps over the lazy dog and keeps going";
|
||||
set_contents(&mut doc, 0, t, long, &meta("")).unwrap();
|
||||
let d = &list(&doc, 0)[t];
|
||||
let r = rect(d);
|
||||
assert_eq!((r[0], r[2], r[3]), (100.0, 304.0, 692.0), "width and top edge stay");
|
||||
let want = expected_box(692.0, 204.0, long, 12.0, 2.0);
|
||||
assert_eq!(r[1], want[1], "the height fits the wrapped lines");
|
||||
// The appearance covers the new rectangle, and every wrapped line is drawn.
|
||||
let n = d.get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap();
|
||||
let bbox: Vec<f64> = doc.get(n).as_dict().unwrap().get(b"BBox").unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect();
|
||||
assert_eq!(bbox, r);
|
||||
assert_eq!(ap_content(&doc, d).matches(" Tj").count(), appearance::wrap(long, 12.0, 204.0 - 2.0 * 4.0).len(), "nothing is clipped");
|
||||
// Shorter text shrinks the box back to one line.
|
||||
set_contents(&mut doc, 0, t, "Hi", &meta("")).unwrap();
|
||||
let r = rect(&list(&doc, 0)[t]);
|
||||
assert_eq!(r, [100.0, expected_box(692.0, 204.0, "Hi", 12.0, 2.0)[1], 304.0, 692.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_a_callout_refits_the_box_and_keeps_the_leader() {
|
||||
let mut doc = fixture();
|
||||
let c = add_annotation(
|
||||
&mut doc,
|
||||
&new(0, Shape::Callout { rect: [300.0, 600.0, 500.0, 612.0], knee: [260.0, 560.0], point: [200.0, 520.0], font_size: 12.0 }),
|
||||
&meta("c"),
|
||||
)
|
||||
.unwrap();
|
||||
let long = "the quick brown fox jumps over the lazy dog and keeps going";
|
||||
set_contents(&mut doc, 0, c, long, &meta("")).unwrap();
|
||||
let d = &list(&doc, 0)[c];
|
||||
let outer = rect(d);
|
||||
let f = |k: &[u8]| -> Vec<f64> { d.get(k).unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect() };
|
||||
let cl = f(b"CL");
|
||||
// The leader line keeps its start and knee; the attach point follows the re-fitted box.
|
||||
assert_eq!(&cl[..4], &[200.0, 520.0, 260.0, 560.0]);
|
||||
let want = expected_box(612.0, 200.0, long, 12.0, 2.0);
|
||||
assert_eq!((cl[4], cl[5]), callout_attach([300.0, want[1], 500.0, 612.0], [260.0, 560.0]).into());
|
||||
// The text box (recovered from /Rect and /RD) keeps its width and top edge.
|
||||
let tb = [outer[0] + f(b"RD")[0], outer[1] + f(b"RD")[1], outer[2] - f(b"RD")[2], outer[3] - f(b"RD")[3]];
|
||||
assert_eq!((tb[0], tb[2], tb[3]), (300.0, 500.0, 612.0));
|
||||
assert_eq!(tb[1], want[1]);
|
||||
// /Rect still grows to hold the leader line: its padded bounds are [186, 506, 274, 574].
|
||||
assert_eq!((outer[0], outer[2], outer[3]), (186.0, 500.0, 612.0));
|
||||
assert_eq!(ap_content(&doc, d).matches(" Tj").count(), appearance::wrap(long, 12.0, 200.0 - 2.0 * 4.0).len(), "nothing is clipped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_locked_text_box_still_refits_its_text() {
|
||||
let mut doc = fixture();
|
||||
let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [100.0, 680.0, 304.0, 692.0], font_size: 12.0 }), &meta("t")).unwrap();
|
||||
set_locked(&mut doc, 0, t, true).unwrap();
|
||||
// Resizing is refused, but the text (and its re-fit) stays editable, as in Acrobat.
|
||||
assert!(matches!(set_rect(&mut doc, 0, t, [0.0, 0.0, 50.0, 50.0], &meta("")), Err(AnnotError::Invalid(_))));
|
||||
let long = "the quick brown fox jumps over the lazy dog";
|
||||
set_contents(&mut doc, 0, t, long, &meta("")).unwrap();
|
||||
let r = rect(&list(&doc, 0)[t]);
|
||||
assert_eq!(r[3], 692.0);
|
||||
let want = expected_box(692.0, 204.0, long, 12.0, 2.0);
|
||||
assert_eq!(r[1], want[1], "the box grew to fit the wrapped lines");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_keys_survive_edits() {
|
||||
let mut doc = fixture();
|
||||
|
||||
@@ -417,6 +417,17 @@ fn comments_through_tools() {
|
||||
assert_eq!(undo["undone"], "Edit comment");
|
||||
ok(&mut a, "edit_redo", json!({ "doc": doc }));
|
||||
|
||||
// Editing a text box re-fits its rectangle to the new text: the wrap width and top edge
|
||||
// stay, the height follows the wrapped lines.
|
||||
let tb = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "FreeText").unwrap().clone();
|
||||
let long = "the quick brown fox jumps over the lazy dog ".repeat(3);
|
||||
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": tb["page"], "index": tb["index"], "contents": long.trim_end() }));
|
||||
let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 }));
|
||||
let tb = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "FreeText").unwrap().clone();
|
||||
let r: Vec<f64> = tb["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||||
assert_eq!((r[0], r[1], r[2] - r[0]), (10.0, 200.0, 180.0), "width and top edge stay: {r:?}");
|
||||
assert!(r[3] - r[1] > 40.0, "the box grew to fit the wrapped lines: {r:?}");
|
||||
|
||||
// The rendered page shows the rectangle's border.
|
||||
let png = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
|
||||
assert!(matches!(png[0], Content::Png { .. }));
|
||||
|
||||
+101
-1
@@ -254,6 +254,80 @@ fn text_page(content: &str) -> Document {
|
||||
Document::open(Arc::new(out)).unwrap()
|
||||
}
|
||||
|
||||
fn descriptor_text_page() -> Document {
|
||||
let content = "BT /F1 12 Tf 72 700 Td (ab) Tj ET";
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
|
||||
"<< /Type /Font /Subtype /TrueType /BaseFont /ABCDEF+NeutralFace /FirstChar 97 /LastChar 98 /Widths [500 500] /FontDescriptor 6 0 R /Encoding /WinAnsiEncoding >>".into(),
|
||||
"<< /Type /FontDescriptor /FontName /NeutralFace /Flags 262208 /ItalicAngle -12 /FontWeight 700 /Ascent 900 /Descent -250 >>".into(),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offs = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offs.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let x = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offs {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
Document::open(Arc::new(out)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn japanese_line_uses_unicode_type3_fallback() {
|
||||
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
|
||||
let replacement = "25362738こんにちは、お元気ですか? 2.3444";
|
||||
let result = text::replace_line(&mut doc, 0, 0, replacement).unwrap();
|
||||
assert_eq!(result.substituted.as_deref(), Some("Shippori Mincho Type3"));
|
||||
let bytes = page_content_bytes(&doc, 0);
|
||||
let content = String::from_utf8_lossy(&bytes);
|
||||
assert!(content.contains("/PCJp"), "{content}");
|
||||
let reopened = reopen(&doc);
|
||||
assert_eq!(text::text_lines(&reopened, 0).unwrap()[0].text, replacement);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn japanese_paragraph_uses_unicode_type3_fallback() {
|
||||
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
|
||||
let replacement = "こんにちは、お元気ですか? 2.3444 日本語の文章";
|
||||
text::replace_block(&mut doc, 0, 0, replacement).unwrap();
|
||||
let reopened = reopen(&doc);
|
||||
assert_eq!(text::text_blocks(&reopened, 0).unwrap()[0].text, replacement);
|
||||
}
|
||||
#[test]
|
||||
fn font_descriptor_style_is_exposed_even_with_a_neutral_name() {
|
||||
let doc = descriptor_text_page();
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert!(lines[0].bold && lines[0].italic, "{:?}", lines[0]);
|
||||
let blocks = text::text_blocks(&doc, 0).unwrap();
|
||||
assert!(blocks[0].bold && blocks[0].italic, "{:?}", blocks[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_font_runs_are_not_merged_into_one_source_style() {
|
||||
let doc = text_page("BT /F1 12 Tf 72 700 Td (Regular) Tj /F2 12 Tf 150 700 Td (Subset) Tj ET");
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert_ne!(lines[0].font, lines[1].font);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_glyphs_preserve_source_style_in_fallback() {
|
||||
let mut doc = descriptor_text_page();
|
||||
let result = text::replace_line(&mut doc, 0, 0, "Styled €").unwrap();
|
||||
assert_eq!(result.substituted.as_deref(), Some("Helvetica-BoldOblique"));
|
||||
let bytes = page_content_bytes(&doc, 0);
|
||||
let content = String::from_utf8_lossy(&bytes);
|
||||
assert!(content.contains("/PCEdHelveticaBoldOblique 12 Tf"), "{content}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_lines_are_found_and_replaced_in_place() {
|
||||
let mut doc =
|
||||
@@ -264,7 +338,6 @@ fn text_lines_are_found_and_replaced_in_place() {
|
||||
assert!((lines[0].rect[0] - 72.0).abs() < 0.01 && lines[0].rect[1] < 700.0 && lines[0].rect[3] > 700.0, "{:?}", lines[0].rect);
|
||||
assert!((lines[0].size - 12.0).abs() < 1e-9 && lines[0].base_font == "Helvetica");
|
||||
let second = lines[1].rect;
|
||||
// Same font: reused; the next line stays where it was.
|
||||
let r = text::replace_line(&mut doc, 0, 0, "Goodbye, café").unwrap();
|
||||
assert_eq!(r.substituted, None);
|
||||
let doc = reopen(&doc);
|
||||
@@ -549,3 +622,30 @@ fn justify_underline_and_spacing() {
|
||||
let w2 = text::text_lines(&doc, 0).unwrap()[0].rect;
|
||||
assert!(((w2[2] - w2[0]) - (w0[2] - w0[0]) / 2.0).abs() < 1.0, "{w0:?} → {w2:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_drawn_twice_is_replaced_everywhere() {
|
||||
// Fake bold: a line drawn twice at the same spot (each copy its own text object). The two
|
||||
// copies group apart, but editing one replaces both — a surviving copy would show the old
|
||||
// text under the new.
|
||||
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello) Tj ET BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
|
||||
assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2, "the copies group apart");
|
||||
text::replace_block(&mut doc, 0, 0, "Hello world").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["Hello world"], "one line, replaced");
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
assert_eq!(content.matches("(Hello").count(), 1, "the second copy's operators are gone: {content}");
|
||||
// Copies in one text object (the matrix re-issued at the same spot) go too.
|
||||
let mut doc = text_page("BT /F1 12 Tf 1 0 0 1 72 700 Tm (Hi) Tj 1 0 0 1 72.3 700 Tm (Hi) Tj ET");
|
||||
text::replace_line(&mut doc, 0, 0, "Hey").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["Hey"], "one line, replaced");
|
||||
// A neighbouring line is not coincident and stays (40 pt spacing groups apart at 12 pt).
|
||||
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello) Tj 0 -40 Td (world) Tj ET");
|
||||
text::replace_block(&mut doc, 0, 0, "Hello there").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["Hello there", "world"]);
|
||||
}
|
||||
+271
-32
@@ -16,6 +16,7 @@ use std::rc::Rc;
|
||||
use printcraft_content::{Matrix, Op, parse, serialize_ops};
|
||||
use printcraft_cos::{Dict, Document, Object, PdfString, Stream};
|
||||
use printcraft_fonts::pdf::Metrics;
|
||||
use printcraft_fonts::{GlyphError, shippori_glyph};
|
||||
|
||||
use crate::EditError;
|
||||
|
||||
@@ -30,6 +31,10 @@ pub struct TextLine {
|
||||
pub font: String,
|
||||
pub base_font: String,
|
||||
pub size: f64,
|
||||
pub bold: bool,
|
||||
pub italic: bool,
|
||||
/// The current fill colour as RGB.
|
||||
pub color: [f64; 3],
|
||||
/// Whether new text in this font can only be shown by substituting another font (no
|
||||
/// Unicode mapping for the line, so nothing could be reused).
|
||||
pub decodable: bool,
|
||||
@@ -104,6 +109,11 @@ pub struct TextBlock {
|
||||
pub rect: [f64; 4],
|
||||
pub base_font: String,
|
||||
pub size: f64,
|
||||
pub bold: bool,
|
||||
pub italic: bool,
|
||||
/// The text fill colour as RGB, used by the visual editor and preserved when no colour
|
||||
/// override is requested.
|
||||
pub color: [f64; 3],
|
||||
/// Indexes into [`text_lines`].
|
||||
pub lines: Vec<usize>,
|
||||
}
|
||||
@@ -159,6 +169,31 @@ fn page_dict(doc: &Document, page: usize) -> Result<printcraft_model::Page, Edit
|
||||
printcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))
|
||||
}
|
||||
|
||||
fn fill_color(fill: &[Op]) -> [f64; 3] {
|
||||
let Some(op) = fill.iter().rev().find(|op| matches!(op.op.as_slice(), b"g" | b"rg" | b"k")) else {
|
||||
return [0.0, 0.0, 0.0];
|
||||
};
|
||||
match op.op.as_slice() {
|
||||
b"g" => {
|
||||
let v = op.operands.first().and_then(Object::as_f64).unwrap_or(0.0).clamp(0.0, 1.0);
|
||||
[v, v, v]
|
||||
}
|
||||
b"rg" => [
|
||||
op.operands.first().and_then(Object::as_f64).unwrap_or(0.0).clamp(0.0, 1.0),
|
||||
op.operands.get(1).and_then(Object::as_f64).unwrap_or(0.0).clamp(0.0, 1.0),
|
||||
op.operands.get(2).and_then(Object::as_f64).unwrap_or(0.0).clamp(0.0, 1.0),
|
||||
],
|
||||
b"k" => {
|
||||
let c = op.operands.first().and_then(Object::as_f64).unwrap_or(0.0);
|
||||
let m = op.operands.get(1).and_then(Object::as_f64).unwrap_or(0.0);
|
||||
let y = op.operands.get(2).and_then(Object::as_f64).unwrap_or(0.0);
|
||||
let k = op.operands.get(3).and_then(Object::as_f64).unwrap_or(0.0);
|
||||
[(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)]
|
||||
}
|
||||
_ => [0.0, 0.0, 0.0],
|
||||
}
|
||||
}
|
||||
|
||||
/// The text-showing operators of one stream with their text, font, box and baseline.
|
||||
struct Shown {
|
||||
op: usize,
|
||||
@@ -177,6 +212,8 @@ struct Shown {
|
||||
font: Vec<u8>,
|
||||
base_font: String,
|
||||
size: f64,
|
||||
bold: bool,
|
||||
italic: bool,
|
||||
decodable: bool,
|
||||
}
|
||||
|
||||
@@ -333,6 +370,8 @@ fn interpret(doc: &Document, ops: &[Op], fonts_res: &Dict, cache: &mut HashMap<V
|
||||
font: name,
|
||||
base_font: m.base_font.clone(),
|
||||
size: if size_user > 0.0 { size_user } else { ts.size },
|
||||
bold: m.bold,
|
||||
italic: m.italic,
|
||||
decodable,
|
||||
});
|
||||
}
|
||||
@@ -355,7 +394,14 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
|
||||
let mut last: Option<(usize, f64, f64, f64)> = None; // (bt, baseline, end_x, size)
|
||||
for s in shown {
|
||||
let joins = last.is_some_and(|(bt, base, end, size)| {
|
||||
bt == s.bt && (s.baseline - base).abs() < size * 0.3 && s.start_x > end - size && s.start_x - end < size * 3.0
|
||||
bt == s.bt
|
||||
&& lines.last().is_some_and(|l| l.font.as_bytes() == s.font.as_slice())
|
||||
&& lines.last().is_some_and(|l| {
|
||||
(s.size - l.size).abs() < 0.01 && s.bold == l.bold && s.italic == l.italic && fill_color(&s.state.fill) == l.color
|
||||
})
|
||||
&& (s.baseline - base).abs() < size * 0.3
|
||||
&& s.start_x > end - size
|
||||
&& s.start_x - end < size * 3.0
|
||||
});
|
||||
if joins && let Some(l) = lines.last_mut() {
|
||||
let gap = s.start_x - last.map_or(s.start_x, |x| x.2);
|
||||
@@ -366,6 +412,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
|
||||
l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])];
|
||||
l.ops.push(s.op);
|
||||
l.decodable &= s.decodable;
|
||||
l.color = fill_color(&s.state.fill);
|
||||
} else {
|
||||
lines.push(TextLine {
|
||||
text: s.text.clone(),
|
||||
@@ -373,6 +420,9 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
|
||||
font: String::from_utf8_lossy(&s.font).into_owned(),
|
||||
base_font: s.base_font.clone(),
|
||||
size: s.size,
|
||||
bold: s.bold,
|
||||
italic: s.italic,
|
||||
color: fill_color(&s.state.fill),
|
||||
decodable: s.decodable,
|
||||
stream: si,
|
||||
ops: vec![s.op],
|
||||
@@ -399,9 +449,133 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
|
||||
/// Text → the bytes that show it in the chosen font.
|
||||
type Encoder = Box<dyn Fn(&str) -> Option<Vec<u8>>>;
|
||||
|
||||
/// The substitute font's resource name.
|
||||
fn is_win_ansi_char(c: char) -> bool {
|
||||
matches!(c, '\u{20}'..='\u{7e}' | '\u{a0}'..='\u{ff}' | '€' | '‚' | '„' | '…' | '‘' | '’' | '“' | '”' | '•' | '–' | '—' | '™' | '\t')
|
||||
}
|
||||
|
||||
fn needs_type3(text: &str) -> bool {
|
||||
text.chars().any(|c| !is_win_ansi_char(c))
|
||||
}
|
||||
|
||||
fn source_family(base_font: &str) -> crate::added::Family {
|
||||
let name = base_font.to_ascii_lowercase();
|
||||
if ["courier", "mono", "consolas", "menlo", "monaco", "lucida console"].iter().any(|s| name.contains(s)) {
|
||||
crate::added::Family::Courier
|
||||
} else if !name.contains("sans") && ["times", "serif", "roman", "cambria", "georgia", "palatino", "garamond"].iter().any(|s| name.contains(s)) {
|
||||
crate::added::Family::Times
|
||||
} else {
|
||||
crate::added::Family::Helvetica
|
||||
}
|
||||
}
|
||||
|
||||
const SUBSTITUTE_NAME: &str = "PCEdHelv";
|
||||
const SUBSTITUTE: &[u8] = SUBSTITUTE_NAME.as_bytes();
|
||||
const MAX_TYPE3_GLYPHS: usize = 240;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Type3Fallback {
|
||||
name: String,
|
||||
codes: Vec<(char, u8, f64)>,
|
||||
}
|
||||
|
||||
fn pdf_num(v: f64) -> String {
|
||||
if v.fract() == 0.0 { format!("{v:.0}") } else { format!("{v:.4}").trim_end_matches('0').trim_end_matches('.').to_string() }
|
||||
}
|
||||
|
||||
fn type3_path(ch: char) -> Result<(Vec<u8>, f64), EditError> {
|
||||
let glyph = shippori_glyph(ch).map_err(|e| match e {
|
||||
GlyphError::Missing => EditError::Invalid(format!("Japanese fallback font has no glyph for U+{:04X}", ch as u32)),
|
||||
GlyphError::TooComplex => EditError::Invalid(format!("Japanese fallback glyph U+{:04X} is too complex", ch as u32)),
|
||||
})?;
|
||||
let scale = 1000.0;
|
||||
let mut out = format!("{} 0 0 0 0 1000 1000 d1\n", pdf_num(glyph.width * scale)).into_bytes();
|
||||
for contour in glyph.contours {
|
||||
let Some(first) = contour.first() else { continue };
|
||||
out.extend_from_slice(format!("{} {} m\n", pdf_num(first[0] * scale), pdf_num(first[1] * scale)).as_bytes());
|
||||
for p in contour.iter().skip(1) {
|
||||
out.extend_from_slice(format!("{} {} l\n", pdf_num(p[0] * scale), pdf_num(p[1] * scale)).as_bytes());
|
||||
}
|
||||
out.extend_from_slice(b"h\n");
|
||||
}
|
||||
out.extend_from_slice(b"f\n");
|
||||
Ok((out, glyph.width))
|
||||
}
|
||||
|
||||
fn unicode_hex(ch: char) -> String {
|
||||
let mut units = [0u16; 2];
|
||||
let encoded = ch.encode_utf16(&mut units);
|
||||
encoded.iter().map(|u| format!("{u:04X}")).collect()
|
||||
}
|
||||
|
||||
fn type3_font(doc: &mut Document, fonts_res: &mut Dict, text: &str) -> Result<Type3Fallback, EditError> {
|
||||
let mut chars = Vec::new();
|
||||
for ch in text.chars() {
|
||||
if !chars.contains(&ch) {
|
||||
if chars.len() >= MAX_TYPE3_GLYPHS {
|
||||
return Err(EditError::Invalid("Japanese replacement has too many unique characters".into()));
|
||||
}
|
||||
chars.push(ch);
|
||||
}
|
||||
}
|
||||
if chars.is_empty() {
|
||||
return Err(EditError::Invalid("replacement text is empty".into()));
|
||||
}
|
||||
let mut codes = Vec::with_capacity(chars.len());
|
||||
let mut charprocs = Dict::new();
|
||||
let mut widths = Vec::with_capacity(chars.len());
|
||||
let mut differences = vec![Object::Int(1)];
|
||||
let mut cmap = String::from(
|
||||
"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CMapType 2 def\n1 begincodespacerange\n<01> <FF>\nendcodespacerange\n",
|
||||
);
|
||||
cmap.push_str(&format!("{} beginbfchar\n", chars.len()));
|
||||
for (i, ch) in chars.into_iter().enumerate() {
|
||||
let code = u8::try_from(i + 1).map_err(|_| EditError::Invalid("Japanese replacement has too many unique characters".into()))?;
|
||||
let glyph_name = format!("g{code:02X}");
|
||||
let (path, width) = type3_path(ch)?;
|
||||
let mut pd = Dict::new();
|
||||
pd.set(b"Length".to_vec(), path.len() as i64);
|
||||
let proc_ref = doc.add(Object::Stream(Stream::from_raw(pd, path)));
|
||||
charprocs.set(glyph_name.as_bytes().to_vec(), Object::Ref(proc_ref));
|
||||
differences.push(Object::name(&glyph_name));
|
||||
widths.push(Object::Real((width * 1000.0).round()));
|
||||
cmap.push_str(&format!("<{code:02X}> <{}>\n", unicode_hex(ch)));
|
||||
codes.push((ch, code, width));
|
||||
}
|
||||
cmap.push_str("endbfchar\nendcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n");
|
||||
let mut cmap_dict = Dict::new();
|
||||
cmap_dict.set(b"Length".to_vec(), cmap.len() as i64);
|
||||
let cmap_ref = doc.add(Object::Stream(Stream::from_raw(cmap_dict, cmap.into_bytes())));
|
||||
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"Name".to_vec(), Object::name("PCJapanese"));
|
||||
font.set(b"FontBBox".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(-300), Object::Int(1000), Object::Int(1000)]));
|
||||
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(codes.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_ref));
|
||||
let mut name = String::from("PCJp");
|
||||
let mut suffix = 0usize;
|
||||
while fonts_res.contains(name.as_bytes()) {
|
||||
suffix = suffix.saturating_add(1);
|
||||
name = format!("PCJp{suffix}");
|
||||
}
|
||||
fonts_res.set(name.as_bytes().to_vec(), Object::Dict(font));
|
||||
Ok(Type3Fallback { name, codes })
|
||||
}
|
||||
|
||||
fn type3_encode(fallback: &Type3Fallback, text: &str) -> Option<Vec<u8>> {
|
||||
text.chars().map(|ch| fallback.codes.iter().find(|(c, _, _)| *c == ch).map(|(_, code, _)| *code)).collect()
|
||||
}
|
||||
|
||||
/// Replace the text of line `line` (an index into [`text_lines`]) on `page` with `text`.
|
||||
pub fn replace_line(doc: &mut Document, page: usize, line: usize, text: &str) -> Result<LineEdit, EditError> {
|
||||
@@ -433,33 +607,48 @@ pub fn replace_line(doc: &mut Document, page: usize, line: usize, text: &str) ->
|
||||
match reused {
|
||||
Some(bytes) => replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])),
|
||||
None => {
|
||||
let win = printcraft_fonts::win_ansi(&text);
|
||||
// WinAnsi turns what it can't show into '?'; refuse rather than print the wrong thing.
|
||||
let back: String = win.iter().map(|b| char::from_u32(u32::from(*b)).unwrap_or('?')).collect();
|
||||
if text.chars().zip(back.chars()).any(|(a, b)| b == '?' && a != '?') {
|
||||
return Err(EditError::Invalid(format!("\"{text}\" has characters neither {} nor Helvetica can show", target.base_font)));
|
||||
if needs_type3(&text) {
|
||||
let fallback = type3_font(doc, &mut fonts_res, &text)?;
|
||||
let bytes = type3_encode(&fallback, &text)
|
||||
.ok_or_else(|| EditError::Invalid(format!("\"{text}\" can't be shown by the Japanese fallback")))?;
|
||||
let size = font_size_before(&ops, first).unwrap_or(target.size);
|
||||
replacement.push(Op::new("Tf", vec![Object::name(&fallback.name), printcraft_content::num(size)]));
|
||||
replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))]));
|
||||
replacement.push(Op::new("Tf", vec![Object::name(&target.font), printcraft_content::num(size)]));
|
||||
substituted = Some("Shippori Mincho Type3".into());
|
||||
} else {
|
||||
let win = printcraft_fonts::win_ansi(&text);
|
||||
// WinAnsi turns what it can't show into '?'; refuse rather than print the wrong thing.
|
||||
let back: String = win.iter().map(|b| char::from_u32(u32::from(*b)).unwrap_or('?')).collect();
|
||||
if text.chars().zip(back.chars()).any(|(a, b)| b == '?' && a != '?') {
|
||||
return Err(EditError::Invalid(format!("\"{text}\" has characters neither {} nor Helvetica can show", target.base_font)));
|
||||
}
|
||||
// The size in text space: the current Tf's size.
|
||||
let size = font_size_before(&ops, first).unwrap_or(target.size);
|
||||
let family = source_family(&target.base_font);
|
||||
let base = family.base_font(target.bold, target.italic);
|
||||
let substitute_name = format!("PCEd{}", base.replace('-', ""));
|
||||
replacement.push(Op::new("Tf", vec![Object::name(&substitute_name), printcraft_content::num(size)]));
|
||||
replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(win))]));
|
||||
replacement.push(Op::new("Tf", vec![Object::name(&target.font), printcraft_content::num(size)]));
|
||||
substituted = Some(base.to_string());
|
||||
let mut f = Dict::new();
|
||||
f.set(b"Type".to_vec(), Object::name("Font"));
|
||||
f.set(b"Subtype".to_vec(), Object::name("Type1"));
|
||||
f.set(b"BaseFont".to_vec(), Object::name(base));
|
||||
f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
|
||||
fonts_res.set(substitute_name.into_bytes(), Object::Dict(f));
|
||||
}
|
||||
// The size in text space: the current Tf's size.
|
||||
let size = font_size_before(&ops, first).unwrap_or(target.size);
|
||||
replacement.push(Op::new("Tf", vec![Object::name(SUBSTITUTE_NAME), printcraft_content::num(size)]));
|
||||
replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(win))]));
|
||||
replacement.push(Op::new("Tf", vec![Object::name(&target.font), printcraft_content::num(size)]));
|
||||
substituted = Some("Helvetica".to_string());
|
||||
let mut f = Dict::new();
|
||||
f.set(b"Type".to_vec(), Object::name("Font"));
|
||||
f.set(b"Subtype".to_vec(), Object::name("Type1"));
|
||||
f.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
|
||||
f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
|
||||
fonts_res.set(SUBSTITUTE.to_vec(), Object::Dict(f));
|
||||
}
|
||||
}
|
||||
// Rebuild: the line's first operator becomes the replacement, its others go.
|
||||
let drop: std::collections::HashSet<usize> = target.ops[1..].iter().copied().collect();
|
||||
// Rebuild: the line's first operator becomes the replacement, its others go. Copies of the
|
||||
// line drawn in the same area go too, so the replacement is all that shows.
|
||||
let drops = coincident_ops(&lines, std::slice::from_ref(&target.rect));
|
||||
let mut new_ops = Vec::with_capacity(ops.len() + replacement.len());
|
||||
for (i, op) in ops.drain(..).enumerate() {
|
||||
if i == first {
|
||||
new_ops.append(&mut replacement);
|
||||
} else if !drop.contains(&i) {
|
||||
} else if !drops.get(&target.stream).is_some_and(|d| d.contains(&i)) {
|
||||
new_ops.push(op);
|
||||
}
|
||||
}
|
||||
@@ -488,6 +677,27 @@ fn font_size_before(ops: &[Op], at: usize) -> Option<f64> {
|
||||
ops[..at].iter().rev().find(|o| o.is("Tf")).and_then(|o| o.num(1))
|
||||
}
|
||||
|
||||
/// The operators of every line drawn in the same area as one of `rects`, grouped by content
|
||||
/// stream. Documents sometimes draw a line more than once (fake bold, an invisible text layer),
|
||||
/// and a surviving copy would show the old text under the replaced line. Copies overlap a
|
||||
/// member's rect by more than half of the smaller rect; neighbouring lines share no area.
|
||||
fn coincident_ops(lines: &[TextLine], rects: &[[f64; 4]]) -> std::collections::HashMap<usize, std::collections::HashSet<usize>> {
|
||||
let area = |r: [f64; 4]| ((r[2] - r[0]) * (r[3] - r[1])).max(0.0);
|
||||
let mut drop = std::collections::HashMap::new();
|
||||
for l in lines {
|
||||
let covered = rects.iter().any(|m| {
|
||||
let ix = (m[2].min(l.rect[2]) - m[0].max(l.rect[0])).max(0.0);
|
||||
let iy = (m[3].min(l.rect[3]) - m[1].max(l.rect[1])).max(0.0);
|
||||
let small = area(*m).min(area(l.rect));
|
||||
small > 0.0 && ix * iy / small > 0.5
|
||||
});
|
||||
if covered {
|
||||
drop.entry(l.stream).or_insert_with(std::collections::HashSet::new).extend(l.ops.iter().copied());
|
||||
}
|
||||
}
|
||||
drop
|
||||
}
|
||||
|
||||
/// The paragraphs on a page (0-based).
|
||||
pub fn text_blocks(doc: &Document, page: usize) -> Result<Vec<TextBlock>, EditError> {
|
||||
let lines = text_lines(doc, page)?;
|
||||
@@ -512,6 +722,10 @@ fn group_blocks(lines: &[TextLine]) -> Vec<TextBlock> {
|
||||
let g = prev.origin.baseline - l.origin.baseline;
|
||||
prev.stream == l.stream
|
||||
&& prev.font == l.font
|
||||
&& prev.base_font == l.base_font
|
||||
&& prev.bold == l.bold
|
||||
&& prev.italic == l.italic
|
||||
&& prev.color == l.color
|
||||
&& (prev.size - l.size).abs() < 0.01
|
||||
&& aligned(prev, l)
|
||||
&& g > l.size * 0.8
|
||||
@@ -534,7 +748,16 @@ fn group_blocks(lines: &[TextLine]) -> Vec<TextBlock> {
|
||||
b.lines.push(i);
|
||||
} else {
|
||||
gap = None;
|
||||
blocks.push(TextBlock { text: l.text.trim().to_string(), rect: l.rect, base_font: l.base_font.clone(), size: l.size, lines: vec![i] });
|
||||
blocks.push(TextBlock {
|
||||
text: l.text.trim().to_string(),
|
||||
rect: l.rect,
|
||||
base_font: l.base_font.clone(),
|
||||
size: l.size,
|
||||
bold: l.bold,
|
||||
italic: l.italic,
|
||||
color: l.color,
|
||||
lines: vec![i],
|
||||
});
|
||||
}
|
||||
}
|
||||
blocks
|
||||
@@ -615,8 +838,9 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option
|
||||
let o_state = ts_state.clone();
|
||||
let metrics = fonts_res.get(font_name.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()).map(|d| Metrics::from_dict(doc, &d));
|
||||
let reuse = style.family.is_none() && metrics.as_ref().is_some_and(|m| m.encode(&text).is_some());
|
||||
let type3 = if !reuse && needs_type3(&text) { Some(type3_font(doc, &mut fonts_res, &text)?) } else { None };
|
||||
// The standard font used when the paragraph's own can't be (chosen, or substituted).
|
||||
let (family, bold, italic) = style.family.unwrap_or((crate::added::Family::Helvetica, false, false));
|
||||
let (family, bold, italic) = style.family.unwrap_or((source_family(&b.base_font), b.bold, b.italic));
|
||||
let std_width = move |s: &str, size: f64| -> f64 {
|
||||
match family {
|
||||
crate::added::Family::Courier => s.chars().count() as f64 * 0.6 * size,
|
||||
@@ -641,6 +865,10 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option
|
||||
.sum::<f64>()
|
||||
})
|
||||
.unwrap_or(0.0),
|
||||
_ if let Some(fallback) = &type3 => {
|
||||
fallback.codes.iter().map(|(ch, _, width)| s.chars().filter(|c| c == ch).count() as f64 * width * size).sum::<f64>()
|
||||
+ s.chars().count() as f64 * o_state.char_spacing
|
||||
}
|
||||
_ => std_width(s, size) + s.chars().count() as f64 * o_state.char_spacing,
|
||||
};
|
||||
t * o_state.scale * k
|
||||
@@ -650,6 +878,11 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option
|
||||
let new_font = !reuse;
|
||||
let (show_font, encode): (String, Encoder) = if let Some(m) = metrics.clone().filter(|_| reuse) {
|
||||
(font_name.clone(), Box::new(move |s: &str| m.encode(s)))
|
||||
} else if let Some(fallback) = type3.clone() {
|
||||
let name = fallback.name.clone();
|
||||
let encoder = fallback.clone();
|
||||
substituted = Some("Shippori Mincho Type3".into());
|
||||
(name, Box::new(move |s: &str| type3_encode(&encoder, s)))
|
||||
} else {
|
||||
let win = printcraft_fonts::win_ansi(&text);
|
||||
let back: String = win.iter().map(|c| char::from_u32(u32::from(*c)).unwrap_or('?')).collect();
|
||||
@@ -673,6 +906,7 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option
|
||||
let lead = match style.line_spacing {
|
||||
Some(m) => size * m.clamp(0.5, 5.0),
|
||||
None if members.len() > 1 => (members[0].origin.baseline - members[1].origin.baseline) / k * size / old_size,
|
||||
None if o_state.leading > 0.0 => o_state.leading,
|
||||
None => size * 1.2,
|
||||
};
|
||||
let n = printcraft_content::num;
|
||||
@@ -746,16 +980,20 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option
|
||||
block_ops.push(Op::new("Q", vec![]));
|
||||
}
|
||||
block_ops.push(Op::new("Q", vec![]));
|
||||
let drop: std::collections::HashSet<usize> = members.iter().flat_map(|l| l.ops.iter().copied()).collect();
|
||||
// The paragraph's operators go, and so does anything drawn in the same area (a fake-bold
|
||||
// second copy, an invisible text layer): the new text is all that may show.
|
||||
let mut drop: std::collections::HashMap<usize, std::collections::HashSet<usize>> =
|
||||
members.iter().flat_map(|l| [(l.stream, l.ops.iter().copied().collect::<std::collections::HashSet<_>>())]).collect();
|
||||
let member_rects: Vec<[f64; 4]> = members.iter().map(|l| l.rect).collect();
|
||||
for (stream, ops) in coincident_ops(&lines, &member_rects) {
|
||||
drop.entry(stream).or_default().extend(ops);
|
||||
}
|
||||
// Text shown earlier in the same text object stays first: the paragraph then goes where it
|
||||
// was, between two halves of the text object, so it keeps its place in reading order.
|
||||
let start = first.ops.first().copied().unwrap_or(o.bt_op);
|
||||
let split = ops
|
||||
.get(o.bt_op..start)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(o.bt_op + i)));
|
||||
let split = ops.get(o.bt_op..start).unwrap_or_default().iter().enumerate().any(|(i, op)| {
|
||||
matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.get(&first.stream).is_some_and(|d| d.contains(&(o.bt_op + i)))
|
||||
});
|
||||
// What the text after the paragraph expects: the state at its BT (or where it was split).
|
||||
let after = if split { &o.state } else { &o.bt_state };
|
||||
block_ops.extend(after.ops().into_iter().filter(|op| !op.is("Tf") || after.font.is_some()));
|
||||
@@ -770,7 +1008,8 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option
|
||||
if i == at {
|
||||
new_ops.append(&mut block_ops);
|
||||
}
|
||||
if !drop.contains(&i) {
|
||||
let keep = drop.get(&first.stream).is_none_or(|d| !d.contains(&i));
|
||||
if keep {
|
||||
new_ops.push(op);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
mod encodings;
|
||||
pub mod pdf;
|
||||
mod script;
|
||||
pub use script::{ScriptOutline, script_outline};
|
||||
pub use script::{GlyphError, GlyphOutline, SHIPPORI_MINCHO, ScriptOutline, script_outline, shippori_glyph};
|
||||
|
||||
/// Approximate advance of `s` in Helvetica (or Arial) at `size` points.
|
||||
pub fn helvetica_width(s: &str, size: f64) -> f64 {
|
||||
|
||||
+24
-3
@@ -50,6 +50,10 @@ pub struct Metrics {
|
||||
/// `/BaseFont`, and whether it is a subset (`ABCDEF+Name`: other glyphs are missing).
|
||||
pub base_font: String,
|
||||
pub subset: bool,
|
||||
/// Whether the PDF identifies this face as bold or italic. These come from the
|
||||
/// font name and, when present, `/FontDescriptor` flags/weight/angle.
|
||||
pub bold: bool,
|
||||
pub italic: bool,
|
||||
/// Bytes per code for writing new text (1 for simple fonts, else the codespace length).
|
||||
code_len: usize,
|
||||
}
|
||||
@@ -76,6 +80,15 @@ fn be(b: &[u8]) -> u32 {
|
||||
b.iter().fold(0u32, |a, x| (a << 8) | u32::from(*x))
|
||||
}
|
||||
|
||||
fn style_from_name(name: &str) -> (bool, bool) {
|
||||
let name = name.to_ascii_lowercase();
|
||||
let name = name.split_once('+').map_or(name.as_str(), |(_, n)| n);
|
||||
(
|
||||
["bold", "black", "heavy", "semibold", "demi"].iter().any(|s| name.contains(s)),
|
||||
["italic", "oblique", "slanted"].iter().any(|s| name.contains(s)),
|
||||
)
|
||||
}
|
||||
|
||||
/// Codespace ranges and CID mappings from an embedded CMap stream.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn parse_cmap(data: &[u8]) -> (Vec<(usize, Vec<u8>, Vec<u8>)>, Vec<(u32, u32, u32)>) {
|
||||
@@ -141,6 +154,8 @@ impl Metrics {
|
||||
unicode: (32..127u8).map(|c| (u32::from(c), char::from(c).to_string())).collect(),
|
||||
base_font: "Helvetica".into(),
|
||||
subset: false,
|
||||
bold: false,
|
||||
italic: false,
|
||||
code_len: 1,
|
||||
}
|
||||
}
|
||||
@@ -149,6 +164,9 @@ impl Metrics {
|
||||
let mut m = Self::read_metrics(doc, font);
|
||||
let base = String::from_utf8_lossy(font.name(b"BaseFont").unwrap_or(b"")).into_owned();
|
||||
m.subset = base.len() > 7 && base.as_bytes()[6] == b'+' && base[..6].bytes().all(|b| b.is_ascii_uppercase());
|
||||
let (bold, italic) = style_from_name(&base);
|
||||
m.bold |= bold;
|
||||
m.italic |= italic;
|
||||
m.base_font = base;
|
||||
m.code_len = match &m.codes {
|
||||
Codes::One => 1,
|
||||
@@ -226,9 +244,7 @@ impl Metrics {
|
||||
m.descent = (bbox[1] * fm[3]).min(-0.1);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}
|
||||
if m.widths.is_empty() {
|
||||
} else if m.widths.is_empty() {
|
||||
let base = String::from_utf8_lossy(font.name(b"BaseFont").unwrap_or(b"")).to_ascii_lowercase();
|
||||
m.std14 = Some(if base.contains("courier") {
|
||||
Std14::Courier
|
||||
@@ -242,6 +258,11 @@ impl Metrics {
|
||||
}
|
||||
}
|
||||
if let Some(d) = descriptor {
|
||||
let flags = d.get(b"Flags").and_then(|v| doc.resolve(v).as_f64()).unwrap_or(0.0).max(0.0) as u32;
|
||||
let angle = d.get(b"ItalicAngle").and_then(|v| doc.resolve(v).as_f64()).unwrap_or(0.0);
|
||||
let weight = d.get(b"FontWeight").and_then(|v| doc.resolve(v).as_f64()).unwrap_or(0.0);
|
||||
m.italic |= flags & 64 != 0 || angle.abs() > 0.1;
|
||||
m.bold |= flags & 262_144 != 0 || weight >= 600.0;
|
||||
let a = d.get(b"Ascent").and_then(|v| doc.resolve(v).as_f64()).unwrap_or(0.0) / 1000.0;
|
||||
let de = d.get(b"Descent").and_then(|v| doc.resolve(v).as_f64()).unwrap_or(0.0) / 1000.0;
|
||||
// Fonts often claim 0; never shrink the glyph box below a sensible minimum.
|
||||
|
||||
+103
-19
@@ -1,5 +1,4 @@
|
||||
//! Typed signatures: a name in the bundled script font (Dancing Script, OFL-1.1) as glyph
|
||||
//! outlines, flattened to polygons. Drawn as filled paths, so no font is embedded in the PDF.
|
||||
//! Typed signatures and bounded glyph outlines from approved bundled fonts.
|
||||
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
use skrifa::outline::{DrawSettings, OutlinePen};
|
||||
@@ -7,8 +6,10 @@ use skrifa::{FontRef, MetadataProvider};
|
||||
|
||||
static FONT: &[u8] = include_bytes!("../../../assets/fonts/DancingScript.ttf");
|
||||
|
||||
/// Outlines of a line of text at a font size of 1 (em units): y up, the baseline at 0, starting
|
||||
/// at x = 0. Fill them with the even-odd (or nonzero) rule.
|
||||
/// The approved Japanese fallback face used by the editor and generated Type 3 fonts.
|
||||
pub static SHIPPORI_MINCHO: &[u8] = include_bytes!("../../../assets/fonts/ShipporiMincho-Regular.ttf");
|
||||
|
||||
/// Outlines of a line of existing text at a font size of 1 (em units).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ScriptOutline {
|
||||
pub contours: Vec<Vec<[f64; 2]>>,
|
||||
@@ -17,62 +18,136 @@ pub struct ScriptOutline {
|
||||
pub descent: f64,
|
||||
}
|
||||
|
||||
/// One glyph outline in em units, with the baseline at y = 0.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct GlyphOutline {
|
||||
pub contours: Vec<Vec<[f64; 2]>>,
|
||||
pub width: f64,
|
||||
pub bbox: [f64; 4],
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GlyphError {
|
||||
Missing,
|
||||
TooComplex,
|
||||
}
|
||||
|
||||
struct Flatten {
|
||||
contours: Vec<Vec<[f64; 2]>>,
|
||||
cur: Vec<[f64; 2]>,
|
||||
scale: f64,
|
||||
dx: f64,
|
||||
points: usize,
|
||||
too_complex: bool,
|
||||
}
|
||||
|
||||
impl Flatten {
|
||||
fn new(scale: f64) -> Self {
|
||||
Self { contours: Vec::new(), cur: Vec::new(), scale, dx: 0.0, points: 0, too_complex: false }
|
||||
}
|
||||
|
||||
fn pt(&self, x: f32, y: f32) -> [f64; 2] {
|
||||
[self.dx + x as f64 * self.scale, y as f64 * self.scale]
|
||||
}
|
||||
|
||||
fn last(&self) -> [f64; 2] {
|
||||
self.cur.last().copied().unwrap_or([self.dx, 0.0])
|
||||
}
|
||||
|
||||
fn push(&mut self, p: [f64; 2]) {
|
||||
const MAX_POINTS: usize = 4096;
|
||||
if self.points >= MAX_POINTS {
|
||||
self.too_complex = true;
|
||||
return;
|
||||
}
|
||||
self.points += 1;
|
||||
self.cur.push(p);
|
||||
}
|
||||
|
||||
fn close(&mut self) {
|
||||
if self.cur.len() > 2 {
|
||||
self.contours.push(std::mem::take(&mut self.cur));
|
||||
} else {
|
||||
self.cur.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const STEPS: usize = 8;
|
||||
|
||||
impl OutlinePen for Flatten {
|
||||
fn move_to(&mut self, x: f32, y: f32) {
|
||||
if self.cur.len() > 2 {
|
||||
self.contours.push(std::mem::take(&mut self.cur));
|
||||
}
|
||||
self.cur.clear();
|
||||
let p = self.pt(x, y);
|
||||
self.cur.push(p);
|
||||
self.close();
|
||||
self.push(self.pt(x, y));
|
||||
}
|
||||
|
||||
fn line_to(&mut self, x: f32, y: f32) {
|
||||
let p = self.pt(x, y);
|
||||
self.cur.push(p);
|
||||
self.push(self.pt(x, y));
|
||||
}
|
||||
|
||||
fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) {
|
||||
let (p0, c, p) = (self.last(), self.pt(cx, cy), self.pt(x, y));
|
||||
for i in 1..=STEPS {
|
||||
let t = i as f64 / STEPS as f64;
|
||||
let u = 1.0 - t;
|
||||
self.cur.push([u * u * p0[0] + 2.0 * u * t * c[0] + t * t * p[0], u * u * p0[1] + 2.0 * u * t * c[1] + t * t * p[1]]);
|
||||
self.push([u * u * p0[0] + 2.0 * u * t * c[0] + t * t * p[0], u * u * p0[1] + 2.0 * u * t * c[1] + t * t * p[1]]);
|
||||
}
|
||||
}
|
||||
|
||||
fn curve_to(&mut self, c0x: f32, c0y: f32, c1x: f32, c1y: f32, x: f32, y: f32) {
|
||||
let (p0, c0, c1, p) = (self.last(), self.pt(c0x, c0y), self.pt(c1x, c1y), self.pt(x, y));
|
||||
for i in 1..=STEPS {
|
||||
let t = i as f64 / STEPS as f64;
|
||||
let u = 1.0 - t;
|
||||
let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
|
||||
self.cur.push([a * p0[0] + b * c0[0] + c * c1[0] + d * p[0], a * p0[1] + b * c0[1] + c * c1[1] + d * p[1]]);
|
||||
self.push([a * p0[0] + b * c0[0] + c * c1[0] + d * p[0], a * p0[1] + b * c0[1] + c * c1[1] + d * p[1]]);
|
||||
}
|
||||
}
|
||||
|
||||
fn close(&mut self) {
|
||||
if self.cur.len() > 2 {
|
||||
self.contours.push(std::mem::take(&mut self.cur));
|
||||
}
|
||||
self.cur.clear();
|
||||
Flatten::close(self);
|
||||
}
|
||||
}
|
||||
|
||||
/// Return one Shippori Mincho glyph, bounded so hostile replacement text cannot allocate
|
||||
/// unbounded outline data.
|
||||
pub fn shippori_glyph(ch: char) -> Result<GlyphOutline, GlyphError> {
|
||||
let Ok(font) = FontRef::new(SHIPPORI_MINCHO) else { return Err(GlyphError::Missing) };
|
||||
let loc = LocationRef::default();
|
||||
let metrics = font.metrics(Size::unscaled(), loc);
|
||||
let scale = 1.0 / metrics.units_per_em.max(1) as f64;
|
||||
let Some(gid) = font.charmap().map(ch) else { return Err(GlyphError::Missing) };
|
||||
let advances = font.glyph_metrics(Size::unscaled(), loc);
|
||||
let width = advances.advance_width(gid).unwrap_or(0.0) as f64 * scale;
|
||||
if !width.is_finite() || width <= 0.0 || width > 2.0 {
|
||||
return Err(GlyphError::Missing);
|
||||
}
|
||||
let Some(glyph) = font.outline_glyphs().get(gid) else { return Err(GlyphError::Missing) };
|
||||
let mut pen = Flatten::new(scale);
|
||||
let _ = glyph.draw(DrawSettings::unhinted(Size::unscaled(), loc), &mut pen);
|
||||
pen.close();
|
||||
if pen.too_complex || pen.contours.len() > 256 {
|
||||
return Err(GlyphError::TooComplex);
|
||||
}
|
||||
let mut bbox = [0.0, 0.0, width, 0.0];
|
||||
let mut any = false;
|
||||
for p in pen.contours.iter().flatten() {
|
||||
if !p[0].is_finite() || !p[1].is_finite() {
|
||||
return Err(GlyphError::Missing);
|
||||
}
|
||||
if !any {
|
||||
bbox = [p[0], p[1], p[0], p[1]];
|
||||
any = true;
|
||||
} else {
|
||||
bbox[0] = bbox[0].min(p[0]);
|
||||
bbox[1] = bbox[1].min(p[1]);
|
||||
bbox[2] = bbox[2].max(p[0]);
|
||||
bbox[3] = bbox[3].max(p[1]);
|
||||
}
|
||||
}
|
||||
Ok(GlyphOutline { contours: pen.contours, width, bbox })
|
||||
}
|
||||
|
||||
/// The outlines of `text` in the script font (characters it lacks are skipped).
|
||||
pub fn script_outline(text: &str) -> ScriptOutline {
|
||||
let Ok(font) = FontRef::new(FONT) else { return ScriptOutline::default() };
|
||||
@@ -82,7 +157,7 @@ pub fn script_outline(text: &str) -> ScriptOutline {
|
||||
let charmap = font.charmap();
|
||||
let glyphs = font.outline_glyphs();
|
||||
let advances = font.glyph_metrics(Size::unscaled(), loc);
|
||||
let mut pen = Flatten { contours: Vec::new(), cur: Vec::new(), scale, dx: 0.0 };
|
||||
let mut pen = Flatten::new(scale);
|
||||
for ch in text.chars() {
|
||||
let Some(gid) = charmap.map(ch) else { continue };
|
||||
if let Some(g) = glyphs.get(gid) {
|
||||
@@ -91,6 +166,7 @@ pub fn script_outline(text: &str) -> ScriptOutline {
|
||||
}
|
||||
pen.dx += advances.advance_width(gid).unwrap_or(0.0) as f64 * scale;
|
||||
}
|
||||
pen.close();
|
||||
ScriptOutline { contours: pen.contours, width: pen.dx, ascent: metrics.ascent as f64 * scale, descent: metrics.descent as f64 * scale }
|
||||
}
|
||||
|
||||
@@ -106,4 +182,12 @@ mod tests {
|
||||
assert!(max_x <= o.width + 0.2);
|
||||
assert!(super::script_outline("").contours.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shippori_resolves_japanese_glyphs() {
|
||||
let g = super::shippori_glyph('こ').expect("bundled font has Japanese glyph");
|
||||
assert!(g.width > 0.2 && g.width < 2.0);
|
||||
assert!(!g.contours.is_empty());
|
||||
assert_eq!(super::shippori_glyph('\u{1f4a9}'), Err(super::GlyphError::Missing));
|
||||
}
|
||||
}
|
||||
+54
-6
@@ -152,6 +152,21 @@ impl Arg {
|
||||
/// than recursed into (a hostile file could otherwise overflow the stack).
|
||||
const MAX_NESTING: usize = 64;
|
||||
|
||||
fn hex4(bytes: &[u8]) -> Option<u16> {
|
||||
if bytes.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
bytes.iter().try_fold(0u16, |value, byte| {
|
||||
let digit = match byte {
|
||||
b'0'..=b'9' => byte - b'0',
|
||||
b'a'..=b'f' => byte - b'a' + 10,
|
||||
b'A'..=b'F' => byte - b'A' + 10,
|
||||
_ => return None,
|
||||
};
|
||||
value.checked_mul(16)?.checked_add(u16::from(digit))
|
||||
})
|
||||
}
|
||||
|
||||
struct Parser<'a> {
|
||||
s: &'a [u8],
|
||||
i: usize,
|
||||
@@ -176,11 +191,30 @@ impl Parser<'_> {
|
||||
b'\\' if self.i < self.s.len() => {
|
||||
let e = self.s[self.i];
|
||||
self.i += 1;
|
||||
out.push(match e {
|
||||
b'n' => b'\n',
|
||||
b't' => b'\t',
|
||||
other => other,
|
||||
});
|
||||
match e {
|
||||
b'n' => out.push(b'\n'),
|
||||
b'r' => out.push(b'\r'),
|
||||
b't' => out.push(b'\t'),
|
||||
b'u' => {
|
||||
let code = hex4(self.s.get(self.i..self.i.checked_add(4)?)?)?;
|
||||
self.i += 4;
|
||||
let code = if (0xD800..=0xDBFF).contains(&code) && self.s.get(self.i..self.i.checked_add(6)?)?.starts_with(b"\\u") {
|
||||
let low = hex4(self.s.get(self.i.checked_add(2)?..self.i.checked_add(6)?)?)?;
|
||||
if (0xDC00..=0xDFFF).contains(&low) {
|
||||
self.i += 6;
|
||||
0x10000 + ((u32::from(code) - 0xD800) << 10) + (u32::from(low) - 0xDC00)
|
||||
} else {
|
||||
u32::from(code)
|
||||
}
|
||||
} else {
|
||||
u32::from(code)
|
||||
};
|
||||
let ch = char::from_u32(code).unwrap_or('\u{FFFD}');
|
||||
let mut encoded = [0u8; 4];
|
||||
out.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes());
|
||||
}
|
||||
other => out.push(other),
|
||||
}
|
||||
}
|
||||
c if c == q => return Some(String::from_utf8_lossy(&out).into_owned()),
|
||||
c => out.push(c),
|
||||
@@ -364,7 +398,15 @@ fn js_str(s: &str) -> String {
|
||||
o.push(c);
|
||||
}
|
||||
'\n' => o.push_str("\\n"),
|
||||
c => o.push(c),
|
||||
'\r' => o.push_str("\\r"),
|
||||
'\t' => o.push_str("\\t"),
|
||||
c if c.is_ascii() && !c.is_control() => o.push(c),
|
||||
c => {
|
||||
let mut units = [0u16; 2];
|
||||
for unit in c.encode_utf16(&mut units) {
|
||||
o.push_str(&format!("\\u{unit:04X}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
o.push('"');
|
||||
@@ -955,6 +997,12 @@ mod tests {
|
||||
let (fj, _) = format_js(&f).unwrap();
|
||||
assert_eq!(parse_format(&fj), Some(f));
|
||||
}
|
||||
let euro = format_js(&Format::Number { decimals: 2, sep: 2, neg: 0, currency: "€".into(), prepend: true }).unwrap().0;
|
||||
assert!(euro.contains("\\u20AC"), "{euro}");
|
||||
assert!(!euro.contains('€'), "{euro}");
|
||||
let pound = format_js(&Format::Number { decimals: 2, sep: 0, neg: 0, currency: "£".into(), prepend: true }).unwrap().0;
|
||||
assert!(pound.contains("\\u00A3"), "{pound}");
|
||||
assert!(!pound.contains('£'), "{pound}");
|
||||
let c = Calculate::Simple { op: CalcOp::Product, fields: vec!["q".into(), "p".into()] };
|
||||
assert_eq!(parse_calculate(&calculate_js(&c).unwrap()), Some(c));
|
||||
let c = Calculate::Notation("a + b".into());
|
||||
|
||||
@@ -1526,7 +1526,7 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
if let Some(e) = comments::composer(ui.ctx(), view, info, prefs) {
|
||||
view.pending_edit = Some(e);
|
||||
}
|
||||
if let Some(e) = crate::edit_text_ui::overlay(ui.ctx(), view) {
|
||||
if let Some(e) = crate::edit_text_ui::overlay(ui.ctx(), view, info) {
|
||||
view.pending_edit = Some(e);
|
||||
}
|
||||
if let Some(e) = crate::forms_ui::overlay(ui.ctx(), view, info, &form, today) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! box, Esc cancels). Click an image to select it: drag to move, drag a corner to resize (keeping
|
||||
//! its proportions), right-click for rotate, flip, replace, save and delete; Delete removes it.
|
||||
|
||||
use egui::{Color32, CornerRadius, Pos2, Rect, Stroke};
|
||||
use egui::{Color32, CornerRadius, FontFamily, FontId, Pos2, Rect, Stroke};
|
||||
use printcraft_engine::Edit;
|
||||
use printcraft_render::DocInfo;
|
||||
|
||||
@@ -19,6 +19,14 @@ pub struct LineEditor {
|
||||
pub text: String,
|
||||
original: String,
|
||||
rect: Rect,
|
||||
/// The original PDF rectangle, in user space. It is converted again each frame so the editor
|
||||
/// stays attached while the page is zoomed, scrolled, or rotated.
|
||||
source_rect: [f32; 4],
|
||||
multiline: bool,
|
||||
/// How far the editor box may grow to the right (screen pixels): a single-line paragraph
|
||||
/// rewraps growing to the page's edge, a multi-line one keeps its width.
|
||||
max_width: f32,
|
||||
/// Current screen-space size. Recomputed from the current page transform before painting.
|
||||
size: f32,
|
||||
focus: bool,
|
||||
/// The Format text panel's values, and what the paragraph had (to send only changes).
|
||||
@@ -71,6 +79,12 @@ pub(crate) fn extras_panel(ui: &mut egui::Ui, e: &mut Extras) -> bool {
|
||||
}
|
||||
|
||||
impl LineEditor {
|
||||
/// How far the box may grow to the right, when the paragraph is a single line (a multi-line
|
||||
/// paragraph rewraps to its own width and the box doesn't grow).
|
||||
pub fn growth(&self) -> Option<f32> {
|
||||
(self.max_width > self.rect.width()).then_some(self.max_width)
|
||||
}
|
||||
|
||||
/// The formatting the panel changed.
|
||||
pub fn style(&self) -> printcraft_engine::BlockStyle {
|
||||
let (l, o) = (&self.look, &self.look0);
|
||||
@@ -94,27 +108,56 @@ impl LineEditor {
|
||||
self.original = self.text.clone();
|
||||
self.focus = true;
|
||||
}
|
||||
|
||||
/// Adopt the rewritten paragraph's current geometry before the next overlay frame.
|
||||
pub(crate) fn refresh_source(&mut self, block: &printcraft_engine::TextBlock) {
|
||||
self.source_rect = block.rect.map(|v| v as f32);
|
||||
self.multiline = block.lines.len() > 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// The look shown for a paragraph: the family and weight guessed from its font's name.
|
||||
fn look_of(b: &printcraft_engine::TextBlock) -> printcraft_engine::AddedText {
|
||||
/// The look shown for a paragraph: the family and weight guessed from its PDF font name.
|
||||
fn source_look(base_font: &str, size: f64, color: [f64; 3], detected_bold: bool, detected_italic: bool) -> printcraft_engine::AddedText {
|
||||
use printcraft_engine::FontFamily as F;
|
||||
let name = b.base_font.to_ascii_lowercase();
|
||||
let name = base_font.to_ascii_lowercase();
|
||||
let family = if ["courier", "mono", "consolas", "menlo", "monaco", "lucida console"].iter().any(|s| name.contains(s)) {
|
||||
F::Courier
|
||||
} else if !name.contains("sans") && ["times", "serif", "roman", "cambria", "georgia", "palatino", "garamond"].iter().any(|s| name.contains(s)) {
|
||||
F::Times
|
||||
} else {
|
||||
F::Helvetica
|
||||
};
|
||||
printcraft_engine::AddedText {
|
||||
family: if name.contains("times") || name.contains("serif") && !name.contains("sans") {
|
||||
F::Times
|
||||
} else if name.contains("courier") || name.contains("mono") {
|
||||
F::Courier
|
||||
} else {
|
||||
F::Helvetica
|
||||
},
|
||||
bold: name.contains("bold") || name.contains("black") || name.contains("heavy"),
|
||||
italic: name.contains("italic") || name.contains("oblique"),
|
||||
size: (b.size * 10.0).round() / 10.0,
|
||||
family,
|
||||
bold: detected_bold || ["bold", "black", "heavy", "semibold", "demi"].iter().any(|s| name.contains(s)),
|
||||
italic: detected_italic || ["italic", "oblique", "slanted"].iter().any(|s| name.contains(s)),
|
||||
size: (size * 10.0).round() / 10.0,
|
||||
color,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn look_of(b: &printcraft_engine::TextBlock) -> printcraft_engine::AddedText {
|
||||
source_look(&b.base_font, b.size, b.color, b.bold, b.italic)
|
||||
}
|
||||
|
||||
fn editor_font(look: &printcraft_engine::AddedText, size: f32) -> FontId {
|
||||
let family = match (look.family, look.bold) {
|
||||
(printcraft_engine::FontFamily::Courier, _) => FontFamily::Monospace,
|
||||
(_, true) => FontFamily::Name("semibold".into()),
|
||||
(_, false) => FontFamily::Proportional,
|
||||
};
|
||||
FontId::new(size, family)
|
||||
}
|
||||
|
||||
fn color32(color: [f64; 3]) -> Color32 {
|
||||
Color32::from_rgb(
|
||||
(color[0].clamp(0.0, 1.0) * 255.0).round() as u8,
|
||||
(color[1].clamp(0.0, 1.0) * 255.0).round() as u8,
|
||||
(color[2].clamp(0.0, 1.0) * 255.0).round() as u8,
|
||||
)
|
||||
}
|
||||
|
||||
/// A selected page image, and what the pointer is doing to it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ImageSelection {
|
||||
@@ -157,6 +200,21 @@ pub(crate) fn image_input(
|
||||
}
|
||||
let pointer = ui.input(|i| i.pointer.hover_pos());
|
||||
let selected = view.image_selection.as_ref().filter(|s| s.page == page).map(|s| s.index).filter(|i| *i < boxes.len());
|
||||
for (i, b) in boxes.iter().enumerate() {
|
||||
let selected_box = selected == Some(i);
|
||||
let hovered = ui.input(|inp| inp.pointer.hover_pos()).is_some_and(|p| b.contains(p));
|
||||
let stroke = if selected_box {
|
||||
Stroke::new(1.5, ACCENT)
|
||||
} else if hovered {
|
||||
Stroke::new(1.5, ACCENT.gamma_multiply(0.7))
|
||||
} else {
|
||||
Stroke::new(0.75, ACCENT.gamma_multiply(0.35))
|
||||
};
|
||||
if selected_box {
|
||||
painter.rect_filled(*b, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04));
|
||||
}
|
||||
painter.rect_stroke(*b, CornerRadius::ZERO, stroke, egui::StrokeKind::Outside);
|
||||
}
|
||||
// The selected image: frame, corner handles, dragging.
|
||||
if let Some(i) = selected {
|
||||
let b = boxes[i];
|
||||
@@ -272,23 +330,38 @@ pub(crate) fn page_input(
|
||||
) -> bool {
|
||||
let boxes: Vec<Rect> = lines.iter().map(|l| xf.user_rect(info, page, l.rect.map(|v| v as f32)).expand(2.0)).collect();
|
||||
let painter = ui.painter();
|
||||
for b in &boxes {
|
||||
painter.rect_stroke(*b, CornerRadius::same(2), Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside);
|
||||
let active = view.line_editor.as_ref().filter(|e| e.page == page).map(|e| e.block);
|
||||
for (i, b) in boxes.iter().enumerate() {
|
||||
if active == Some(i) {
|
||||
painter.rect_filled(b.expand(1.0), CornerRadius::same(2), ACCENT.gamma_multiply(0.08));
|
||||
painter.rect_stroke(*b, CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
|
||||
} else {
|
||||
painter.rect_stroke(*b, CornerRadius::same(2), Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside);
|
||||
}
|
||||
}
|
||||
let Some(p) = ui.input(|i| i.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)) else { return false };
|
||||
let Some(hit) = boxes.iter().position(|b| b.contains(p)) else { return false };
|
||||
painter.rect_stroke(boxes[hit], CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
|
||||
let Some(hit) = boxes.iter().rposition(|b| b.contains(p)) else { return false };
|
||||
if active != Some(hit) {
|
||||
painter.rect_stroke(boxes[hit], CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
|
||||
}
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
|
||||
if resp.clicked() {
|
||||
let l = &lines[hit];
|
||||
// Screen pixels per point, from the box's width.
|
||||
let scale = (boxes[hit].width() - 4.0) / ((l.rect[2] - l.rect[0]).max(1.0) as f32);
|
||||
// How far the editor box may grow: a single-line paragraph's rewrite grows to the
|
||||
// page's right edge, a multi-line paragraph rewraps to its own width.
|
||||
let right = xf.rect.right().min(view.viewport_rect().right()) - 6.0;
|
||||
let max_width = if l.lines.len() == 1 { (right - boxes[hit].left()).max(boxes[hit].width()) } else { boxes[hit].width() };
|
||||
view.line_editor = Some(LineEditor {
|
||||
page,
|
||||
block: hit,
|
||||
text: l.text.clone(),
|
||||
original: l.text.clone(),
|
||||
rect: boxes[hit],
|
||||
source_rect: l.rect.map(|v| v as f32),
|
||||
multiline: l.lines.len() > 1,
|
||||
max_width,
|
||||
size: (l.size as f32 * scale).clamp(8.0, 72.0),
|
||||
focus: true,
|
||||
look: look_of(l),
|
||||
@@ -301,23 +374,46 @@ pub(crate) fn page_input(
|
||||
}
|
||||
|
||||
/// The inline editor; returns the edit once the text is applied.
|
||||
pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView) -> Option<Edit> {
|
||||
pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -> Option<Edit> {
|
||||
// Clicks outside the document (the Format text panel) keep the paragraph open.
|
||||
let outside = ctx.input(|i| i.pointer.latest_pos()).is_some_and(|p| !view.viewport_rect().contains(p));
|
||||
let page = view.line_editor.as_ref()?.page;
|
||||
let xf = view.page_xform(page)?;
|
||||
let viewport_right = view.viewport_rect().right();
|
||||
let ed = view.line_editor.as_mut()?;
|
||||
// Reproject the source box every frame. The page may have been zoomed, scrolled or rotated
|
||||
// while the format panel was open.
|
||||
ed.rect = xf.user_rect(info, ed.page, ed.source_rect).expand(2.0);
|
||||
let right = xf.rect.right().min(viewport_right) - 6.0;
|
||||
ed.max_width = if ed.multiline { ed.rect.width() } else { (right - ed.rect.left()).max(ed.rect.width()) };
|
||||
let scale = (ed.rect.width() / (ed.source_rect[2] - ed.source_rect[0]).abs().max(1.0)).max(0.01);
|
||||
ed.size = (ed.look.size as f32 * scale).clamp(8.0, 72.0);
|
||||
let font = editor_font(&ed.look, ed.size);
|
||||
let text_color = color32(ed.look.color);
|
||||
let mut done = None;
|
||||
egui::Area::new(egui::Id::new("edit-text-line")).order(egui::Order::Foreground).fixed_pos(Pos2::new(ed.rect.left(), ed.rect.top())).show(
|
||||
ctx,
|
||||
|ui| {
|
||||
// The box follows the text as you type: as wide as the longest drafted line needs
|
||||
// (up to what the rewrite allows), so new content grows the box instead of wrapping
|
||||
// inside the old one.
|
||||
let mut width = ed.rect.width().max(120.0);
|
||||
if ed.max_width > width {
|
||||
let needed = ui.fonts_mut(|f| {
|
||||
ed.text.lines().map(|l| f.layout_no_wrap(l.to_owned(), font.clone(), text_color).size().x).fold(0.0_f32, f32::max)
|
||||
}) + 8.0;
|
||||
width = width.max(needed).min(ed.max_width);
|
||||
}
|
||||
egui::Frame::NONE.fill(Color32::WHITE).stroke(Stroke::new(1.5, ACCENT)).inner_margin(egui::Margin::symmetric(2, 0)).show(ui, |ui| {
|
||||
let rows = ed.text.lines().count().max(1);
|
||||
let r = ui.add(
|
||||
egui::TextEdit::multiline(&mut ed.text)
|
||||
.id(egui::Id::new("edit-text-line-input"))
|
||||
.font(egui::FontId::proportional(ed.size))
|
||||
.text_color(Color32::BLACK)
|
||||
.font(font.clone())
|
||||
.text_color(text_color)
|
||||
.frame(egui::Frame::NONE)
|
||||
.desired_width(ed.rect.width().max(120.0))
|
||||
.desired_rows(((ed.rect.height() / (ed.size * 1.2)).round() as usize).max(1)),
|
||||
.desired_width(width)
|
||||
.desired_rows(rows),
|
||||
);
|
||||
if ed.focus {
|
||||
r.request_focus();
|
||||
|
||||
@@ -288,6 +288,11 @@ fn format_section(app: &mut PrintCraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let edit = printcraft_engine::Edit::EditTextBlock { page: ed.page, block: ed.block, text: ed.text.clone(), style: ed.style() };
|
||||
if app.apply_edit(edit) {
|
||||
ed.applied();
|
||||
if let Some(doc) = app.session.get(app.views[i].id)
|
||||
&& let Some(block) = doc.text_blocks(ed.page).get(ed.block)
|
||||
{
|
||||
ed.refresh_source(block);
|
||||
}
|
||||
}
|
||||
app.views[i].line_editor = Some(ed);
|
||||
}
|
||||
|
||||
@@ -113,7 +113,9 @@ pub fn install_fonts(ctx: &egui::Context) {
|
||||
add(&mut fonts, "Inter-Medium", include_bytes!("../../../assets/fonts/Inter-Medium.ttf"));
|
||||
add(&mut fonts, "Inter-SemiBold", include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"));
|
||||
add(&mut fonts, "JetBrainsMono", include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"));
|
||||
add(&mut fonts, "ShipporiMincho", include_bytes!("../../../assets/fonts/ShipporiMincho-Regular.ttf"));
|
||||
fonts.families.entry(FontFamily::Proportional).or_default().insert(0, "Inter".to_owned());
|
||||
fonts.families.entry(FontFamily::Proportional).or_default().push("ShipporiMincho".to_owned());
|
||||
fonts.families.entry(FontFamily::Monospace).or_default().insert(0, "JetBrainsMono".to_owned());
|
||||
let fallback: Vec<String> = fonts.families[&FontFamily::Proportional].clone();
|
||||
for (fam, primary) in [("medium", "Inter-Medium"), ("semibold", "Inter-SemiBold")] {
|
||||
|
||||
@@ -630,6 +630,74 @@ fn copying_cutting_and_pasting_pages() {
|
||||
assert_eq!(page_texts(h.state()).len(), 5);
|
||||
}
|
||||
|
||||
fn source_font_fixture() -> Vec<u8> {
|
||||
let body = "BT /F1 18 Tf 20 220 Td (Serif) Tj ET BT /F2 18 Tf 20 120 Td (Mono) Tj ET";
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 300 300] >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Times-BoldItalic >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 7 0 R /Resources << /Font << /F1 3 0 R /F2 5 0 R >> >> >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Oblique >>".into(),
|
||||
"<< /Producer (PrintCraft) >>".into(),
|
||||
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn open_source_font_fixture() -> Harness<'static, PrintCraftApp> {
|
||||
Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PrintCraftApp::new();
|
||||
app.open_bytes("fonts.pdf", None, source_font_fixture()).expect("font fixture opens");
|
||||
app
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_existing_text_selects_its_source_font_style() {
|
||||
let mut h = open_source_font_fixture();
|
||||
h.run_steps(4);
|
||||
assert!(h.state_mut().execute("edit.edit_text"));
|
||||
h.run_steps(2);
|
||||
let page = h.state().views[0].page_screen_rect(0).expect("on screen");
|
||||
let click = |x: f32, y: f32| egui::pos2(page.left() + x / 300.0 * page.width(), page.top() + (300.0 - y) / 300.0 * page.height());
|
||||
|
||||
let serif = click(25.0, 228.0);
|
||||
h.hover_at(serif);
|
||||
h.run_steps(1);
|
||||
h.drag_at(serif);
|
||||
h.run_steps(1);
|
||||
h.drop_at(serif);
|
||||
h.run_steps(3);
|
||||
let ed = h.state().views[0].line_editor.clone().expect("serif editor opens");
|
||||
assert_eq!(ed.look.family, printcraft_engine::FontFamily::Times);
|
||||
assert!(ed.look.bold && ed.look.italic, "source style: {:?}", ed.look);
|
||||
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(2);
|
||||
let mono = click(25.0, 128.0);
|
||||
h.hover_at(mono);
|
||||
h.run_steps(1);
|
||||
h.drag_at(mono);
|
||||
h.run_steps(1);
|
||||
h.drop_at(mono);
|
||||
h.run_steps(3);
|
||||
let ed = h.state().views[0].line_editor.clone().expect("mono editor opens");
|
||||
assert_eq!(ed.look.family, printcraft_engine::FontFamily::Courier);
|
||||
assert!(!ed.look.bold && ed.look.italic, "source style: {:?}", ed.look);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_existing_text_in_place() {
|
||||
let mut h = harness(1, |_| {});
|
||||
@@ -646,6 +714,8 @@ fn editing_existing_text_in_place() {
|
||||
h.run_steps(3);
|
||||
let ed = h.state().views[0].line_editor.clone().expect("the line opens for editing");
|
||||
assert_eq!((ed.page, ed.block, ed.text.as_str()), (0, 0, "Page 1"));
|
||||
// A single-line paragraph's box may grow to the page's edge as the text does.
|
||||
assert!(ed.growth().is_some(), "the editor box may grow");
|
||||
h.state_mut().views[0].line_editor.as_mut().unwrap().text = "Chapter One".into();
|
||||
h.run_steps(1);
|
||||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Enter);
|
||||
@@ -657,6 +727,63 @@ fn editing_existing_text_in_place() {
|
||||
assert_eq!(texts_of(s, 0), ["Chapter One"], "the page shows it");
|
||||
}
|
||||
|
||||
/// One page whose only line is drawn twice at the same spot (fake bold, as many generated
|
||||
/// documents do).
|
||||
fn double_drawn() -> Vec<u8> {
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>".into(),
|
||||
format!(
|
||||
"<< /Length {} >>\nstream\nBT /F1 24 Tf 20 150 Td (Page 1) Tj ET BT /F1 24 Tf 20 150 Td (Page 1) Tj ET\nendstream",
|
||||
"BT /F1 24 Tf 20 150 Td (Page 1) Tj ET BT /F1 24 Tf 20 150 Td (Page 1) Tj ET".len()
|
||||
),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_a_double_drawn_line_replaces_every_copy() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PrintCraftApp::new();
|
||||
app.open_bytes("bold.pdf", None, double_drawn()).expect("opens");
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
assert!(h.state_mut().execute("edit.edit_text"));
|
||||
h.run_steps(2);
|
||||
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
|
||||
let at = egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height());
|
||||
h.hover_at(at);
|
||||
h.run_steps(1);
|
||||
h.drag_at(at);
|
||||
h.run_steps(1);
|
||||
h.drop_at(at);
|
||||
h.run_steps(3);
|
||||
let ed = h.state().views[0].line_editor.clone().expect("the line opens for editing");
|
||||
assert_eq!(ed.text, "Page 1");
|
||||
h.state_mut().views[0].line_editor.as_mut().unwrap().text = "Replaced".into();
|
||||
h.run_steps(1);
|
||||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Enter);
|
||||
h.run_steps(4);
|
||||
let s = h.state();
|
||||
assert_eq!(s.session.get(s.views[0].id).unwrap().text_lines(0).len(), 1, "one line");
|
||||
assert_eq!(texts_of(s, 0), ["Replaced"], "no copy of the old text shows under it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_existing_images_on_the_page() {
|
||||
// A page made from a 40 × 20 image (at 72 dpi: a 40 × 20 pt page filled by it).
|
||||
|
||||
@@ -3578,8 +3578,8 @@ milestone = "M5"
|
||||
status = "shipped"
|
||||
commands = ["comment.callout"]
|
||||
tools = ["comment_add"]
|
||||
evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/ui-egui/tests/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/automation.rs::drawing_comments_through_tools"]
|
||||
notes = "drag from the target to the box; resizing re-attaches the leader"
|
||||
evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/annot/src/tests.rs::editing_a_callout_refits_the_box_and_keeps_the_leader", "crates/ui-egui/tests/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/automation.rs::drawing_comments_through_tools"]
|
||||
notes = "drag from the target to the box; resizing re-attaches the leader, and so does editing the text"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.pencil"
|
||||
@@ -3866,8 +3866,8 @@ milestone = "M5"
|
||||
status = "shipped"
|
||||
commands = ["comment.list"]
|
||||
tools = ["comment_edit"]
|
||||
evidence = ["crates/annot/src/tests.rs::text_box_text_and_colour_changes_are_drawn", "crates/automation/tests/automation.rs::comments_through_tools"]
|
||||
notes = "double-click a comment or card, or Edit in the menus"
|
||||
evidence = ["crates/annot/src/tests.rs::text_box_text_and_colour_changes_are_drawn", "crates/annot/src/tests.rs::editing_a_text_box_refits_its_rectangle", "crates/automation/tests/automation.rs::comments_through_tools"]
|
||||
notes = "double-click a comment or card, or Edit in the menus; a text box's rectangle follows the new text (wrap width and top edge stay, the height fits the wrapped lines)"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.copy-comment-text"
|
||||
|
||||
Reference in new issue
Block a user