mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-26 19:37:03 -06:00
Merge origin/main
This commit is contained in:
+3
-1
File diff suppressed because one or more lines are too long
@@ -420,7 +420,7 @@ fn trigger_save_file_picker(
|
||||
bytes: &[u8],
|
||||
) -> Result<(), String> {
|
||||
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||
let ext = std::path::Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("docx").to_ascii_lowercase();
|
||||
let ext = std::path::Path::new(name).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("docx").to_ascii_lowercase();
|
||||
let dot_ext = format!(".{ext}");
|
||||
let mime = mime_for(name);
|
||||
|
||||
|
||||
@@ -207,7 +207,7 @@ fn on_path(program: &str) -> bool {
|
||||
fn save_exts(name: &str) -> Vec<String> {
|
||||
let suggested = std::path::Path::new(name)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.and_then(std::ffi::OsStr::to_str)
|
||||
.map(|e| e.to_ascii_lowercase())
|
||||
.filter(|e| wordcraft_engine::io::SAVE_EXTS.contains(&e.as_str()))
|
||||
.unwrap_or_else(|| "docx".to_string());
|
||||
|
||||
@@ -17,7 +17,7 @@ pub fn discover() -> Discover {
|
||||
static JOBS: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
pub fn finish_pending_jobs() {
|
||||
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(|poison| poison.into_inner()));
|
||||
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
|
||||
for worker in workers {
|
||||
if worker.join().is_err() {
|
||||
log::error!("the print worker stopped unexpectedly during shutdown");
|
||||
@@ -46,7 +46,7 @@ pub fn submit() -> Submit {
|
||||
let _ = sender.send(craft_print::submit(&bytes, &job).map_err(|error| error.to_string()));
|
||||
});
|
||||
if let Ok(worker) = worker {
|
||||
let mut jobs = JOBS.lock().unwrap_or_else(|poison| poison.into_inner());
|
||||
let mut jobs = JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
jobs.retain(|job| !job.is_finished());
|
||||
jobs.push(worker);
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ fn arg_text(s: &str) -> String {
|
||||
}
|
||||
// A switch: its letters, then an optional (signed) number.
|
||||
Some(_) => {
|
||||
while it.next_if(|n| n.is_ascii_alphabetic()).is_some() {}
|
||||
while it.next_if(char::is_ascii_alphabetic).is_some() {}
|
||||
while it.next_if(|n| *n == ' ').is_some() {}
|
||||
while it.next_if(|n| n.is_ascii_digit() || *n == '-' || *n == '.').is_some() {}
|
||||
}
|
||||
|
||||
+26
-24
@@ -229,7 +229,7 @@ impl Path {
|
||||
|
||||
impl std::fmt::Display for Path {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let s: Vec<String> = self.0.iter().map(|i| i.to_string()).collect();
|
||||
let s: Vec<String> = self.0.iter().map(ToString::to_string).collect();
|
||||
write!(f, "{}", s.join("/"))
|
||||
}
|
||||
}
|
||||
@@ -801,34 +801,36 @@ impl Document {
|
||||
d
|
||||
}
|
||||
|
||||
/// Every story must hold at least one paragraph, and every cell too.
|
||||
pub fn ensure_nonempty(&mut self) {
|
||||
fn fix(bl: &mut Blocks) {
|
||||
if !bl.iter().any(|b| matches!(**b, Block::Para(_))) || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
|
||||
bl.push(para_block(Paragraph::new()));
|
||||
}
|
||||
for b in bl.iter_mut() {
|
||||
if let Block::Table(t) = &**b {
|
||||
let needs = t
|
||||
.rows
|
||||
.iter()
|
||||
.any(|r| r.cells.iter().any(|c| c.blocks.is_empty() || matches!(c.blocks.last().map(|b| &**b), Some(Block::Table(_)))));
|
||||
if needs && let Block::Table(t) = Arc::make_mut(b) {
|
||||
for r in &mut t.rows {
|
||||
for c in &mut r.cells {
|
||||
fix_cell(&mut c.blocks);
|
||||
}
|
||||
fn fix_cell_blocks(bl: &mut Blocks) {
|
||||
if bl.is_empty() || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
|
||||
bl.push(para_block(Paragraph::new()));
|
||||
}
|
||||
}
|
||||
|
||||
fn fix_story_blocks(bl: &mut Blocks) {
|
||||
if !bl.iter().any(|b| matches!(**b, Block::Para(_))) || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
|
||||
bl.push(para_block(Paragraph::new()));
|
||||
}
|
||||
for b in bl.iter_mut() {
|
||||
if let Block::Table(t) = &**b {
|
||||
let needs = t
|
||||
.rows
|
||||
.iter()
|
||||
.any(|r| r.cells.iter().any(|c| c.blocks.is_empty() || matches!(c.blocks.last().map(|b| &**b), Some(Block::Table(_)))));
|
||||
if needs && let Block::Table(t) = Arc::make_mut(b) {
|
||||
for r in &mut t.rows {
|
||||
for c in &mut r.cells {
|
||||
Self::fix_cell_blocks(&mut c.blocks);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn fix_cell(bl: &mut Blocks) {
|
||||
if bl.is_empty() || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
|
||||
bl.push(para_block(Paragraph::new()));
|
||||
}
|
||||
}
|
||||
fix(&mut self.body);
|
||||
}
|
||||
|
||||
/// Every story must hold at least one paragraph, and every cell too.
|
||||
pub fn ensure_nonempty(&mut self) {
|
||||
Self::fix_story_blocks(&mut self.body);
|
||||
for p in self.parts.values_mut() {
|
||||
if p.blocks.is_empty() {
|
||||
p.blocks.push(para_block(Paragraph::new()));
|
||||
|
||||
@@ -47,7 +47,7 @@ fn tokenize(s: &str) -> Vec<Tok> {
|
||||
match c {
|
||||
'\\' => {
|
||||
let start = i;
|
||||
while cs.get(i).is_some_and(|c| c.is_ascii_alphabetic()) {
|
||||
while cs.get(i).is_some_and(char::is_ascii_alphabetic) {
|
||||
i += 1;
|
||||
}
|
||||
if i == start {
|
||||
|
||||
@@ -339,7 +339,7 @@ impl P {
|
||||
/// A prefix structure after its character.
|
||||
fn prefix(&mut self, c: char, stops: &[char]) -> MNode {
|
||||
match c {
|
||||
'√' | '∛' | '∜' => {
|
||||
'√'..='∜' => {
|
||||
let mut deg = match c {
|
||||
'∛' => vec![text('3')],
|
||||
'∜' => vec![text('4')],
|
||||
@@ -410,7 +410,7 @@ impl P {
|
||||
fn word(&mut self, stops: &[char]) -> Arg {
|
||||
let start = self.i;
|
||||
let mut end = start;
|
||||
while self.c.get(end).is_some_and(|c| c.is_ascii_alphabetic()) {
|
||||
while self.c.get(end).is_some_and(char::is_ascii_alphabetic) {
|
||||
end += 1;
|
||||
}
|
||||
let word: String = self.c.get(start..end).unwrap_or(&[]).iter().collect();
|
||||
@@ -628,7 +628,7 @@ impl P {
|
||||
// Word (#1609): the whole run of letters and digits up to a space or operator, so
|
||||
// `V_dd` is V with a subscript `dd`, exactly like `V_(dd)`.
|
||||
let start = self.i;
|
||||
while self.peek().is_some_and(|k| k.is_alphanumeric()) {
|
||||
while self.peek().is_some_and(char::is_alphanumeric) {
|
||||
self.i += 1;
|
||||
}
|
||||
let t: String = self.c.get(start..self.i).unwrap_or(&[]).iter().collect();
|
||||
|
||||
@@ -311,7 +311,14 @@ pub enum ShapeKind {
|
||||
RightTriangle,
|
||||
Diamond,
|
||||
Line,
|
||||
/// A block arrow pointing right (`rightArrow`; also stands for the double-headed arrows).
|
||||
Arrow,
|
||||
/// A block arrow pointing left (`leftArrow`).
|
||||
ArrowLeft,
|
||||
/// A block arrow pointing up (`upArrow`).
|
||||
ArrowUp,
|
||||
/// A block arrow pointing down (`downArrow`).
|
||||
ArrowDown,
|
||||
Star,
|
||||
Heart,
|
||||
/// A text box (rectangle with a text story).
|
||||
|
||||
@@ -342,6 +342,9 @@ impl Highlight {
|
||||
}
|
||||
}
|
||||
|
||||
/// The text size, in points, of a run whose size no style or default sets.
|
||||
pub const DEFAULT_FONT_SIZE: f32 = 11.0;
|
||||
|
||||
/// Character formatting (direct or in a style). `None` = inherit.
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
|
||||
@@ -78,7 +78,7 @@ impl ResolvedChar {
|
||||
ResolvedChar {
|
||||
// A symbol character is shown in its own font (`w:sym`), whatever the run's font.
|
||||
font: c.shown_font().map(str::to_string).unwrap_or_else(|| crate::styles::BODY_FONT.to_string()),
|
||||
size: c.size.unwrap_or(11.0).clamp(1.0, 1638.0),
|
||||
size: c.size.unwrap_or(crate::props::DEFAULT_FONT_SIZE).clamp(1.0, 1638.0),
|
||||
bold: c.bold.unwrap_or(false),
|
||||
italic: c.italic.unwrap_or(false),
|
||||
underline: c.underline.unwrap_or_default(),
|
||||
@@ -113,7 +113,7 @@ impl ResolvedChar {
|
||||
cs: c.cs.unwrap_or(false),
|
||||
font_cs: c.font_cs.clone().or_else(|| c.font.clone()).unwrap_or_else(|| crate::styles::BODY_FONT.to_string()),
|
||||
font_ea: c.font_ea.clone(),
|
||||
size_cs: c.size_cs.or(c.size).unwrap_or(11.0).clamp(1.0, 1638.0),
|
||||
size_cs: c.size_cs.or(c.size).unwrap_or(crate::props::DEFAULT_FONT_SIZE).clamp(1.0, 1638.0),
|
||||
bold_cs: c.bold_cs.or(c.bold).unwrap_or(false),
|
||||
italic_cs: c.italic_cs.or(c.italic).unwrap_or(false),
|
||||
}
|
||||
|
||||
+34
-5
@@ -92,11 +92,7 @@ pub fn shape_outline(kind: ShapeKind, w: f32, h: f32, corner: Option<u32>) -> Op
|
||||
ShapeKind::Triangle => vec![(0.5, 0.0), (1.0, 1.0), (0.0, 1.0)],
|
||||
ShapeKind::RightTriangle => vec![(0.0, 0.0), (1.0, 1.0), (0.0, 1.0)],
|
||||
ShapeKind::Diamond => vec![(0.5, 0.0), (1.0, 0.5), (0.5, 1.0), (0.0, 0.5)],
|
||||
ShapeKind::Arrow => {
|
||||
// As drawn: the head is half the height long, the shaft 40% of the height thick.
|
||||
let head = if w > 0.0 { (0.5 * h / w).min(1.0) } else { 0.5 };
|
||||
vec![(0.0, 0.3), (1.0 - head, 0.3), (1.0 - head, 0.0), (1.0, 0.5), (1.0 - head, 1.0), (1.0 - head, 0.7), (0.0, 0.7)]
|
||||
}
|
||||
ShapeKind::Arrow | ShapeKind::ArrowLeft | ShapeKind::ArrowUp | ShapeKind::ArrowDown => arrow_outline(kind, w, h),
|
||||
ShapeKind::Star => (0..10)
|
||||
.map(|i| {
|
||||
let a = PI * i as f32 / 5.0 - FRAC_PI_2;
|
||||
@@ -126,6 +122,26 @@ pub fn shape_outline(kind: ShapeKind, w: f32, h: f32, corner: Option<u32>) -> Op
|
||||
Some(pts)
|
||||
}
|
||||
|
||||
/// A block arrow's outline in unit coordinates (0..1 across a `w` × `h` frame), pointing the way
|
||||
/// `kind` says (right for anything but the left, up and down arrows). The head is half the arrow's
|
||||
/// thickness long, the shaft 40% of the thickness thick.
|
||||
pub fn arrow_outline(kind: ShapeKind, w: f32, h: f32) -> Vec<(f32, f32)> {
|
||||
let vertical = matches!(kind, ShapeKind::ArrowUp | ShapeKind::ArrowDown);
|
||||
let (len, thick) = if vertical { (h, w) } else { (w, h) };
|
||||
let head = if len > 0.0 { (0.5 * thick / len).min(1.0) } else { 0.5 };
|
||||
// Pointing right: `u` runs along the arrow from its tail, `v` across it.
|
||||
let right = [(0.0, 0.3), (1.0 - head, 0.3), (1.0 - head, 0.0), (1.0, 0.5), (1.0 - head, 1.0), (1.0 - head, 0.7), (0.0, 0.7)];
|
||||
right
|
||||
.iter()
|
||||
.map(|&(u, v)| match kind {
|
||||
ShapeKind::ArrowLeft => (1.0 - u, v),
|
||||
ShapeKind::ArrowUp => (v, 1.0 - u),
|
||||
ShapeKind::ArrowDown => (v, u),
|
||||
_ => (u, v),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Largest side of the grid [`alpha_outline`] looks at.
|
||||
pub const ALPHA_GRID: usize = 48;
|
||||
|
||||
@@ -214,6 +230,16 @@ mod tests {
|
||||
assert!(WrapPolygon::from_unit(false, &[(f32::NAN, 0.0), (0.0, 0.0), (1.0, 0.0), (0.5, 1.0)]).is_some_and(|p| p.points.len() == 3));
|
||||
}
|
||||
|
||||
/// #3919: the tip of each block arrow lies on the side it points to.
|
||||
#[test]
|
||||
fn arrow_outlines_point_the_right_way() {
|
||||
let tip = |kind| shape_outline(kind, 100.0, 100.0, None).unwrap()[3];
|
||||
assert_eq!(tip(ShapeKind::Arrow), (1.0, 0.5));
|
||||
assert_eq!(tip(ShapeKind::ArrowLeft), (0.0, 0.5));
|
||||
assert_eq!(tip(ShapeKind::ArrowUp), (0.5, 0.0));
|
||||
assert_eq!(tip(ShapeKind::ArrowDown), (0.5, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_outlines_stay_in_the_frame() {
|
||||
for kind in [
|
||||
@@ -223,6 +249,9 @@ mod tests {
|
||||
ShapeKind::RightTriangle,
|
||||
ShapeKind::Diamond,
|
||||
ShapeKind::Arrow,
|
||||
ShapeKind::ArrowLeft,
|
||||
ShapeKind::ArrowUp,
|
||||
ShapeKind::ArrowDown,
|
||||
ShapeKind::Star,
|
||||
ShapeKind::Heart,
|
||||
] {
|
||||
|
||||
@@ -190,7 +190,10 @@ fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -
|
||||
5 => ShapeKind::Triangle,
|
||||
6 => ShapeKind::RightTriangle,
|
||||
12 | 187 => ShapeKind::Star,
|
||||
13 | 66 | 67 | 68 | 69 => ShapeKind::Arrow,
|
||||
13 | 69 => ShapeKind::Arrow,
|
||||
66 => ShapeKind::ArrowLeft,
|
||||
67 => ShapeKind::ArrowDown,
|
||||
68 => ShapeKind::ArrowUp,
|
||||
20 | 32 => ShapeKind::Line,
|
||||
74 => ShapeKind::Heart,
|
||||
202 => ShapeKind::TextBox,
|
||||
|
||||
@@ -70,7 +70,7 @@ fn fbse_png(png: &[u8]) -> Vec<u8> {
|
||||
|
||||
/// UTF-16LE, NUL-terminated (a complex string property).
|
||||
fn wz(s: &str) -> Vec<u8> {
|
||||
s.encode_utf16().chain([0]).flat_map(|u| u.to_le_bytes()).collect()
|
||||
s.encode_utf16().chain([0]).flat_map(u16::to_le_bytes).collect()
|
||||
}
|
||||
|
||||
/// A `Spa` (26 bytes): spid, bounds in twips, flags, cTxbx.
|
||||
|
||||
@@ -231,22 +231,11 @@ impl PropCtx {
|
||||
// `w:ascii`, `w:hAnsiTheme` over `w:hAnsi`).
|
||||
let named = |a: &str| k.attr(a).filter(|f| !f.is_empty()).map(str::to_string);
|
||||
let themed = |a: &str| k.attr(a).and_then(|t| self.theme_font(t));
|
||||
c.font =
|
||||
themed("w:asciiTheme").or_else(|| named("w:ascii")).or_else(|| themed("w:hAnsiTheme")).or_else(|| named("w:hAnsi")).or_else(
|
||||
|| {
|
||||
// `w:eastAsia` and `w:cs` name fonts for other scripts; they stand in for the
|
||||
// Latin font only when the run is hinted as that script. Otherwise the Latin
|
||||
// font is inherited (a Normal style with only `w:eastAsia`/`w:cs` keeps the
|
||||
// `docDefaults` font, as in Word).
|
||||
match k.attr("w:hint") {
|
||||
Some("eastAsia") => k.attr("w:eastAsia"),
|
||||
Some("cs") => k.attr("w:cs"),
|
||||
_ => None,
|
||||
}
|
||||
.filter(|f| !f.is_empty())
|
||||
.map(str::to_string)
|
||||
},
|
||||
);
|
||||
// `w:eastAsia` and `w:cs` name fonts for other scripts and stay in `font_ea` /
|
||||
// `font_cs`: `w:hint` only picks between them for ambiguous characters, never for
|
||||
// Latin letters. A Normal style with only `w:eastAsia`/`w:cs` keeps the
|
||||
// `docDefaults` font, and so does a hinted run, as in Word.
|
||||
c.font = themed("w:asciiTheme").or_else(|| named("w:ascii")).or_else(|| themed("w:hAnsiTheme")).or_else(|| named("w:hAnsi"));
|
||||
// The complex-script font (Persian, Arabic, Hebrew text). A `w:cstheme` font is
|
||||
// left to inherit: the theme's per-script fonts aren't read.
|
||||
c.font_cs = k.attr("w:cs").filter(|f| !f.is_empty() && f.len() < 256).map(str::to_string);
|
||||
|
||||
@@ -41,9 +41,10 @@ pub(crate) fn preset_kind(prst: &str) -> ShapeKind {
|
||||
"straightConnector1" => ShapeKind::StraightConnector,
|
||||
"bentConnector2" | "bentConnector3" | "bentConnector4" | "bentConnector5" => ShapeKind::ElbowConnector,
|
||||
"curvedConnector2" | "curvedConnector3" | "curvedConnector4" | "curvedConnector5" => ShapeKind::CurvedConnector,
|
||||
"rightArrow" | "leftArrow" | "upArrow" | "downArrow" | "leftRightArrow" | "upDownArrow" | "notchedRightArrow" | "stripedRightArrow" => {
|
||||
ShapeKind::Arrow
|
||||
}
|
||||
"rightArrow" | "leftRightArrow" | "upDownArrow" | "notchedRightArrow" | "stripedRightArrow" => ShapeKind::Arrow,
|
||||
"leftArrow" => ShapeKind::ArrowLeft,
|
||||
"upArrow" => ShapeKind::ArrowUp,
|
||||
"downArrow" => ShapeKind::ArrowDown,
|
||||
"star4" | "star5" | "star6" | "star7" | "star8" | "star10" | "star12" | "star16" | "star24" | "star32" => ShapeKind::Star,
|
||||
"heart" => ShapeKind::Heart,
|
||||
_ => ShapeKind::Rectangle,
|
||||
@@ -469,6 +470,15 @@ mod tests {
|
||||
assert_eq!(preset_kind("flowChartDecision"), ShapeKind::Diamond);
|
||||
}
|
||||
|
||||
/// #3919: Word's up, down and left block arrows were all read as the right-pointing arrow.
|
||||
#[test]
|
||||
fn block_arrows_keep_their_direction() {
|
||||
assert_eq!(preset_kind("rightArrow"), ShapeKind::Arrow);
|
||||
assert_eq!(preset_kind("leftArrow"), ShapeKind::ArrowLeft);
|
||||
assert_eq!(preset_kind("upArrow"), ShapeKind::ArrowUp);
|
||||
assert_eq!(preset_kind("downArrow"), ShapeKind::ArrowDown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vml_presets_map_like_drawingml_presets() {
|
||||
assert_eq!(vml_preset_kind(13), Some(ShapeKind::Arrow));
|
||||
@@ -477,7 +487,7 @@ mod tests {
|
||||
assert_eq!(vml_preset_kind(202), Some(ShapeKind::TextBox));
|
||||
// The nearest shape, as `preset_kind` gives it: left arrow, right triangle, four-point
|
||||
// star, rounded callout, elbow connector, a bevel (its box).
|
||||
assert_eq!(vml_preset_kind(66), Some(ShapeKind::Arrow));
|
||||
assert_eq!(vml_preset_kind(66), Some(ShapeKind::ArrowLeft));
|
||||
assert_eq!(vml_preset_kind(6), Some(ShapeKind::RightTriangle));
|
||||
assert_eq!(vml_preset_kind(187), Some(ShapeKind::Star));
|
||||
assert_eq!(vml_preset_kind(62), Some(ShapeKind::RoundedRectangle));
|
||||
|
||||
@@ -1751,7 +1751,7 @@ impl Reader<'_> {
|
||||
let font = tp
|
||||
.attr("style")
|
||||
.and_then(|s| vml_style(s, "font-family"))
|
||||
.map(|f| f.trim_matches(|c: char| c == '"' || c == '\'' || c.is_whitespace()).chars().take(100).collect::<String>())
|
||||
.map(|f| css_string(f).chars().take(100).collect::<String>())
|
||||
.filter(|f| !f.is_empty());
|
||||
// The shapetype's WordArt shape (`o:spt` 136-175) bends the text like DrawingML's warp.
|
||||
let warp = vml_spt(shape, shapetype, type_id)
|
||||
@@ -2231,6 +2231,49 @@ fn vml_style<'a>(style: &'a str, key: &str) -> Option<&'a str> {
|
||||
style.rsplit(';').filter_map(|d| d.split_once(':')).find(|(k, _)| k.trim().eq_ignore_ascii_case(key)).map(|(_, v)| v.trim())
|
||||
}
|
||||
|
||||
/// A `v:textpath` style value with one pair of surrounding quotes removed and the writer's CSS
|
||||
/// escapes mapped back: `\` + up to six hex digits + one optional space is that character, and
|
||||
/// `\x` is `x` (#3994; `write::watermark::run` writes them). Only the surrounding pair goes, so a
|
||||
/// font name that starts or ends with an apostrophe keeps it. Word writes font names raw, and a
|
||||
/// lone trailing `\` none of CSS would accept stays literal.
|
||||
fn css_string(v: &str) -> String {
|
||||
let v = v.trim();
|
||||
let v = ['"', '\''].into_iter().find_map(|q| v.strip_prefix(q)?.strip_suffix(q)).unwrap_or(v).trim();
|
||||
let mut out = String::with_capacity(v.len());
|
||||
let mut chars = v.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c != '\\' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
let mut hex = String::new();
|
||||
while hex.len() < 6 {
|
||||
match chars.clone().next() {
|
||||
Some(h) if h.is_ascii_hexdigit() => {
|
||||
hex.push(h);
|
||||
chars.next();
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
if hex.is_empty() {
|
||||
match chars.next() {
|
||||
Some(e) => out.push(e),
|
||||
None => out.push('\\'),
|
||||
}
|
||||
} else {
|
||||
if let Some(d) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
|
||||
out.push(d);
|
||||
}
|
||||
// One whitespace after the escape is its terminator, not part of the value.
|
||||
if chars.clone().next().is_some_and(|w| w.is_whitespace()) {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Placement of a VML shape (ECMA-376 Part 4, VML): `position:absolute` in its style makes it
|
||||
/// float, offset by `left`/`margin-left` and `top`/`margin-top` from the area named by
|
||||
/// `mso-position-horizontal-relative` / `mso-position-vertical-relative` (unless aligned with
|
||||
@@ -2332,7 +2375,7 @@ fn vml_watermark(shape: &El) -> Option<wordcraft_doc::Watermark> {
|
||||
let font = tp
|
||||
.attr("style")
|
||||
.and_then(|s| vml_style(s, "font-family"))
|
||||
.map(|f| f.trim_matches(|c: char| c == '"' || c == '\'' || c.is_whitespace()).chars().take(100).collect::<String>())
|
||||
.map(|f| css_string(f).chars().take(100).collect::<String>())
|
||||
.filter(|f| !f.is_empty())
|
||||
.unwrap_or(default.font);
|
||||
let color = shape.attr("fillcolor").and_then(vml_color).unwrap_or(default.color);
|
||||
|
||||
@@ -31,7 +31,7 @@ pub fn measure(s: &str, per_pt: f32) -> Option<f32> {
|
||||
Some(&s) if s == b'+' || s == b'-' => &rest[1..],
|
||||
_ => rest,
|
||||
};
|
||||
if after_sign.first().is_some_and(|c| c.is_ascii_digit()) {
|
||||
if after_sign.first().is_some_and(u8::is_ascii_digit) {
|
||||
in_exp = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -720,7 +720,15 @@ fn styles_xml(doc: &Document) -> Vec<u8> {
|
||||
w.open("w:docDefaults", &[]);
|
||||
w.open("w:rPrDefault", &[]);
|
||||
w.open("w:rPr", &[]);
|
||||
props::rpr_inner(&mut w, &s.default_chr);
|
||||
if s.default_chr.size.is_none() {
|
||||
// The reader reads an omitted `w:sz` as 10 pt, but the resolver falls back to
|
||||
// DEFAULT_FONT_SIZE, so writing nothing would change the size on reopening.
|
||||
let mut chr = s.default_chr.clone();
|
||||
chr.size = Some(wordcraft_doc::props::DEFAULT_FONT_SIZE);
|
||||
props::rpr_inner(&mut w, &chr);
|
||||
} else {
|
||||
props::rpr_inner(&mut w, &s.default_chr);
|
||||
}
|
||||
w.close("w:rPr");
|
||||
w.close("w:rPrDefault");
|
||||
if s.default_para_present || s.default_para != wordcraft_doc::props::ParaProps::default() {
|
||||
|
||||
@@ -29,6 +29,43 @@ enum Piece<'a> {
|
||||
}
|
||||
|
||||
impl Writer<'_> {
|
||||
fn update_toc_before_block(&mut self, i: usize, toc: Option<(usize, usize)>, toc_field_open: bool, w: &mut W) {
|
||||
if let Some((start, end)) = toc {
|
||||
if i == start {
|
||||
w.raw(TOC_SDT_OPEN);
|
||||
self.toc_hold_end = true;
|
||||
}
|
||||
self.toc_begin_here = toc_field_open && i == start + 1;
|
||||
self.toc_end_here = toc_field_open && i == end;
|
||||
}
|
||||
}
|
||||
|
||||
fn update_toc_after_block(&mut self, i: usize, toc: Option<(usize, usize)>, toc_field_open: &mut bool, w: &mut W) {
|
||||
if let Some((start, end)) = toc {
|
||||
if i == start {
|
||||
// `field` holds the heading's TOC field back for the first entry.
|
||||
self.toc_hold_end = false;
|
||||
*toc_field_open = self.toc_field.is_some();
|
||||
}
|
||||
if i == end {
|
||||
w.raw("</w:sdtContent></w:sdt>");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_trailing_para(&mut self, w: &mut W, depth: usize) {
|
||||
// A header with no paragraph of its own before: the watermark goes in this one.
|
||||
if depth == 0
|
||||
&& let Some(run) = self.pending_watermark.take()
|
||||
{
|
||||
w.raw("<w:p>");
|
||||
w.raw(&run);
|
||||
w.raw("</w:p>");
|
||||
} else {
|
||||
w.raw("<w:p/>");
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a block list. `top` = body level (sections allowed).
|
||||
pub fn blocks(&mut self, w: &mut W, bl: &Blocks, rels: &mut PartRels, top: bool, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
@@ -43,14 +80,7 @@ impl Writer<'_> {
|
||||
for c in plan.opens.get(i).into_iter().flatten() {
|
||||
super::sdt::open(w, c);
|
||||
}
|
||||
if let Some((start, end)) = toc {
|
||||
if i == start {
|
||||
w.raw(TOC_SDT_OPEN);
|
||||
self.toc_hold_end = true;
|
||||
}
|
||||
self.toc_begin_here = toc_field_open && i == start + 1;
|
||||
self.toc_end_here = toc_field_open && i == end;
|
||||
}
|
||||
self.update_toc_before_block(i, toc, toc_field_open, w);
|
||||
match &**b {
|
||||
Block::Para(p) => {
|
||||
self.inline_controls = plan.inline_in(i);
|
||||
@@ -58,16 +88,7 @@ impl Writer<'_> {
|
||||
}
|
||||
Block::Table(t) => self.table(w, t, rels, depth),
|
||||
}
|
||||
if let Some((start, end)) = toc {
|
||||
if i == start {
|
||||
// `field` holds the heading's TOC field back for the first entry.
|
||||
self.toc_hold_end = false;
|
||||
toc_field_open = self.toc_field.is_some();
|
||||
}
|
||||
if i == end {
|
||||
w.raw("</w:sdtContent></w:sdt>");
|
||||
}
|
||||
}
|
||||
self.update_toc_after_block(i, toc, &mut toc_field_open, w);
|
||||
for _ in 0..plan.closes.get(i).copied().unwrap_or(0) {
|
||||
super::sdt::close(w);
|
||||
}
|
||||
@@ -76,16 +97,7 @@ impl Writer<'_> {
|
||||
self.move_ranges(w, &[]);
|
||||
}
|
||||
if bl.last().is_none_or(|b| matches!(**b, Block::Table(_))) {
|
||||
// A header with no paragraph of its own before: the watermark goes in this one.
|
||||
if depth == 0
|
||||
&& let Some(run) = self.pending_watermark.take()
|
||||
{
|
||||
w.raw("<w:p>");
|
||||
w.raw(&run);
|
||||
w.raw("</w:p>");
|
||||
} else {
|
||||
w.raw("<w:p/>");
|
||||
}
|
||||
self.write_trailing_para(w, depth);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +170,23 @@ impl Writer<'_> {
|
||||
self.para_inner(w, p, rels, top, depth, false);
|
||||
}
|
||||
|
||||
fn write_mark_rpr(&mut self, w: &mut W, mark: &CharProps, mark_revs: &[(u32, &str)]) {
|
||||
w.open("w:rPr", &[]);
|
||||
for (idx, tag) in mark_revs {
|
||||
let (id, author, date) = self.rev_attrs(*idx);
|
||||
if date.is_empty() {
|
||||
w.empty(tag, &[("w:id", &id), ("w:author", &author)]);
|
||||
} else {
|
||||
w.empty(tag, &[("w:id", &id), ("w:author", &author), ("w:date", &date)]);
|
||||
}
|
||||
}
|
||||
rpr_inner(w, mark);
|
||||
if let Some(ch) = mark.fmt_change.as_deref() {
|
||||
self.rpr_change(w, ch);
|
||||
}
|
||||
w.close("w:rPr");
|
||||
}
|
||||
|
||||
fn para_inner(&mut self, w: &mut W, p: &Paragraph, rels: &mut PartRels, top: bool, depth: usize, framed: bool) {
|
||||
let para_id = self.take_para_id(p);
|
||||
match ¶_id {
|
||||
@@ -185,20 +214,7 @@ impl Writer<'_> {
|
||||
});
|
||||
ppr_inner(w, &pp, framed, num_change.as_ref());
|
||||
if has_mark {
|
||||
w.open("w:rPr", &[]);
|
||||
for (idx, tag) in mark_revs {
|
||||
let (id, author, date) = self.rev_attrs(idx);
|
||||
if date.is_empty() {
|
||||
w.empty(tag, &[("w:id", &id), ("w:author", &author)]);
|
||||
} else {
|
||||
w.empty(tag, &[("w:id", &id), ("w:author", &author), ("w:date", &date)]);
|
||||
}
|
||||
}
|
||||
rpr_inner(w, &p.mark);
|
||||
if let Some(ch) = p.mark.fmt_change.as_deref() {
|
||||
self.rpr_change(w, ch);
|
||||
}
|
||||
w.close("w:rPr");
|
||||
self.write_mark_rpr(w, &p.mark, &mark_revs);
|
||||
}
|
||||
if let Some(s) = section {
|
||||
self.sectpr(w, s, rels);
|
||||
@@ -1053,6 +1069,9 @@ impl Writer<'_> {
|
||||
ShapeKind::ElbowConnector => "bentConnector3",
|
||||
ShapeKind::CurvedConnector => "curvedConnector3",
|
||||
ShapeKind::Arrow => "rightArrow",
|
||||
ShapeKind::ArrowLeft => "leftArrow",
|
||||
ShapeKind::ArrowUp => "upArrow",
|
||||
ShapeKind::ArrowDown => "downArrow",
|
||||
ShapeKind::Star => "star5",
|
||||
ShapeKind::Heart => "heart",
|
||||
};
|
||||
|
||||
@@ -23,8 +23,21 @@ pub(super) fn text(wm: &Watermark) -> Option<String> {
|
||||
pub(super) fn run(wm: &Watermark, n: usize, page: &SectionProps) -> Option<String> {
|
||||
let text = text(wm)?;
|
||||
let [width, height] = wm.frame(page.page_w, page.page_h);
|
||||
// A font name can't break out of the textpath's CSS-like style.
|
||||
let font: String = wm.font.chars().filter(|c| !matches!(c, '"' | '\'' | ';' | ':')).take(100).collect();
|
||||
// A font name can't break out of the textpath's CSS-like style or make it malformed (#3994):
|
||||
// the characters a quoted CSS string can't carry raw (`\22 ` is `"`, `\3b ` `;`, `\3a ` `:`,
|
||||
// `\5c ` `\`) are escaped and control characters are dropped (no font name carries one); the
|
||||
// reader (`read::story::css_string`) maps the escapes back. An apostrophe is legal inside a
|
||||
// double-quoted string, so it is kept.
|
||||
let mut font = String::with_capacity(wm.font.len());
|
||||
for c in wm.font.chars().filter(|c| !c.is_control()).take(100) {
|
||||
match c {
|
||||
'"' => font.push_str(r"\22 "),
|
||||
';' => font.push_str(r"\3b "),
|
||||
':' => font.push_str(r"\3a "),
|
||||
'\\' => font.push_str(r"\5c "),
|
||||
c => font.push(c),
|
||||
}
|
||||
}
|
||||
let font = if font.trim().is_empty() { Watermark::default().font } else { font };
|
||||
let rotation = if wm.diagonal { "rotation:315;" } else { "" };
|
||||
let style = format!(
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! The document default font size (`w:docDefaults/w:rPrDefault/w:rPr/w:sz`) across a save and
|
||||
//! reopen. An unset default is resolved as `DEFAULT_FONT_SIZE`; the writer must say so, because
|
||||
//! the reader reads an omitted size as 10 pt.
|
||||
use std::io::{Read, Write};
|
||||
use wordcraft_doc::Document;
|
||||
use wordcraft_doc::props::{CharProps, DEFAULT_FONT_SIZE};
|
||||
fn resolved(d: &Document) -> f32 {
|
||||
d.styles.resolve_char(None, &CharProps::default()).size
|
||||
}
|
||||
fn styles_xml(bytes: &[u8]) -> String {
|
||||
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
archive.by_name("word/styles.xml").unwrap().read_to_string(&mut xml).unwrap();
|
||||
xml
|
||||
}
|
||||
fn defaults(xml: &str) -> &str {
|
||||
xml.split("<w:docDefaults>").nth(1).unwrap().split("</w:docDefaults>").next().unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn an_unset_default_size_keeps_its_effective_size_across_save_and_reopen() {
|
||||
let mut d = Document::new();
|
||||
d.styles.default_chr.size = None;
|
||||
assert_eq!(resolved(&d), DEFAULT_FONT_SIZE);
|
||||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||||
assert!(defaults(&styles_xml(&bytes)).contains(r#"<w:sz w:val="22"/>"#));
|
||||
let reopened = wordcraft_docx::read(&bytes).unwrap();
|
||||
assert_eq!(resolved(&reopened), DEFAULT_FONT_SIZE);
|
||||
// Saving again changes nothing further.
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&reopened).unwrap()).unwrap();
|
||||
assert_eq!(resolved(&again), DEFAULT_FONT_SIZE);
|
||||
}
|
||||
#[test]
|
||||
fn an_explicit_default_size_is_written_as_it_is() {
|
||||
for size in [8.0, 10.0, 11.0, 12.5, 18.0] {
|
||||
let mut d = Document::new();
|
||||
d.styles.default_chr.size = Some(size);
|
||||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||||
assert_eq!(resolved(&wordcraft_docx::read(&bytes).unwrap()), size);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn a_file_that_omits_the_default_size_is_still_read_as_ten_points() {
|
||||
// The reader is unchanged: only what WordCraft writes differs. A package without a size
|
||||
// anywhere keeps its established reading.
|
||||
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>x</w:t></w:r></w:p></w:body></w:document>"#;
|
||||
let styles = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:docDefaults><w:rPrDefault><w:rPr/></w:rPrDefault></w:docDefaults></w:styles>"#;
|
||||
let rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, body) in [("_rels/.rels", root), ("word/document.xml", doc), ("word/_rels/document.xml.rels", rels), ("word/styles.xml", styles)] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(body.as_bytes()).unwrap();
|
||||
}
|
||||
let d = wordcraft_docx::read(&zw.finish().unwrap().into_inner()).unwrap();
|
||||
assert_eq!(d.styles.default_chr.size, Some(10.0));
|
||||
}
|
||||
#[test]
|
||||
fn an_unset_default_size_does_not_take_a_styles_size() {
|
||||
// The written default is the resolver's own fallback, not what the Normal style resolves to,
|
||||
// so a style that sets a size keeps it and one that does not still falls back to the default.
|
||||
let mut d = Document::new();
|
||||
d.styles.default_chr.size = None;
|
||||
if let Some(normal) = d.styles.get_mut("Normal") {
|
||||
normal.chr.size = Some(14.0);
|
||||
}
|
||||
let reopened = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(reopened.styles.default_chr.size, Some(DEFAULT_FONT_SIZE));
|
||||
assert_eq!(resolved(&reopened), resolved(&d));
|
||||
}
|
||||
@@ -1040,7 +1040,7 @@ fn documents_without_a_compatibility_mode_are_laid_out_as_word_2007() {
|
||||
#[test]
|
||||
fn style_rfonts_without_ascii_inherits_doc_defaults_font() {
|
||||
// Issue #93: Normal names only East Asian / complex-script fonts, so Latin text keeps the
|
||||
// docDefaults font. A run hinted as East Asian still uses its East Asian font.
|
||||
// docDefaults font. A run hinted as East Asian keeps its East Asian font in `font_ea` (#4031).
|
||||
let styles = format!(
|
||||
r#"<w:styles {W_NS}>
|
||||
<w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="Calibri" w:eastAsia="Calibri" w:hAnsi="Calibri" w:cs="Calibri"/></w:rPr></w:rPrDefault><w:pPrDefault/></w:docDefaults>
|
||||
@@ -1051,9 +1051,28 @@ fn style_rfonts_without_ascii_inherits_doc_defaults_font() {
|
||||
let d = wordcraft_docx::read(&docx(body, &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
|
||||
assert_eq!(d.styles.get("Normal").unwrap().chr.font, None);
|
||||
let ps = paras(&d);
|
||||
let font = |p: &Paragraph| d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props).font;
|
||||
assert_eq!(font(ps[0]), "Calibri");
|
||||
assert_eq!(font(ps[1]), "SimSun");
|
||||
let rc = |p: &Paragraph| d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props);
|
||||
assert_eq!(rc(ps[0]).font, "Calibri");
|
||||
assert_eq!((rc(ps[1]).font.as_str(), rc(ps[1]).font_ea.as_deref()), ("Calibri", Some("SimSun")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hinted_east_asian_or_cs_font_does_not_replace_the_latin_font() {
|
||||
// Issue #4031: `w:hint` only picks the font for ambiguous characters, so a run with
|
||||
// `w:eastAsia` + `w:hint="eastAsia"` and no `w:ascii` keeps the style's Latin font for its
|
||||
// Latin text; the East Asian / complex-script fonts stay in their own fields.
|
||||
let styles = format!(
|
||||
r#"<w:styles {W_NS}>
|
||||
<w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:rPr><w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman" w:cs="Times New Roman" w:eastAsiaTheme="majorEastAsia"/><w:b/></w:rPr></w:style>
|
||||
</w:styles>"#
|
||||
);
|
||||
let body = r#"<w:p><w:pPr><w:pStyle w:val="Heading2"/></w:pPr><w:r><w:rPr><w:rFonts w:eastAsia="宋体" w:hint="eastAsia"/></w:rPr><w:t>corn straw</w:t></w:r><w:r><w:rPr><w:rFonts w:cs="Arial" w:hint="cs"/></w:rPr><w:t>model fluids</w:t></w:r></w:p>"#;
|
||||
let d = wordcraft_docx::read(&docx(body, &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
|
||||
let p = paras(&d)[0];
|
||||
let ea = d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props);
|
||||
assert_eq!((ea.font.as_str(), ea.font_ea.as_deref()), ("Times New Roman", Some("宋体")));
|
||||
let cs = d.styles.resolve_char(p.props.style.as_deref(), &p.runs[1].props);
|
||||
assert_eq!((cs.font.as_str(), cs.font_cs.as_str()), ("Times New Roman", "Arial"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2387,7 +2406,7 @@ fn vml_preset_shapes_are_drawn() {
|
||||
assert_eq!(kind(15), Some((ShapeKind::Rectangle, false)));
|
||||
assert_eq!(kind(16), Some((ShapeKind::Diamond, false)));
|
||||
assert_eq!(kind(17), Some((ShapeKind::Ellipse, false)));
|
||||
assert_eq!(shape(18), Some((ShapeKind::Arrow, 60.0, 20.0, Some(Rgb(0xFF, 0xA5, 0x00)), black, 0.75, true, false)));
|
||||
assert_eq!(shape(18), Some((ShapeKind::ArrowLeft, 60.0, 20.0, Some(Rgb(0xFF, 0xA5, 0x00)), black, 0.75, true, false)));
|
||||
assert_eq!(kind(19), Some((ShapeKind::Rectangle, false)));
|
||||
assert_eq!(kind(20), Some((ShapeKind::Star, false)));
|
||||
assert_eq!(shape(21), None);
|
||||
@@ -2458,12 +2477,12 @@ fn vml_nearest_preset_shape_survives_saving() {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(saved.as_slice())).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
assert!(xml.contains(r#"prst="rightArrow""#), "{xml}");
|
||||
assert!(xml.contains(r#"prst="leftArrow""#), "{xml}");
|
||||
let again = wordcraft_docx::read(&saved).unwrap();
|
||||
assert!(
|
||||
matches!(
|
||||
¶s(&again)[0].objects[..],
|
||||
[InlineObject::Shape { kind: ShapeKind::Arrow, w: 60.0, h: 20.0, fill: Some(Fill::Solid { color: Rgb(0xFF, 0xA5, 0x00), .. }), .. }]
|
||||
[InlineObject::Shape { kind: ShapeKind::ArrowLeft, w: 60.0, h: 20.0, fill: Some(Fill::Solid { color: Rgb(0xFF, 0xA5, 0x00), .. }), .. }]
|
||||
),
|
||||
"{:?}",
|
||||
paras(&again)[0].objects
|
||||
|
||||
@@ -7,6 +7,7 @@ mod complex_script_inheritance;
|
||||
mod content_control_save_cost;
|
||||
mod content_controls;
|
||||
mod custom_xml;
|
||||
mod default_size;
|
||||
mod drawing_payload;
|
||||
mod east_asian_lang;
|
||||
mod east_asian_layout;
|
||||
|
||||
@@ -1545,6 +1545,33 @@ fn watermark_roundtrips() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #3994: a watermark font name round-trips through the textpath's CSS-like style whatever it
|
||||
/// carries — apostrophes, semicolons, colons, quotes, backslashes. Before, those characters
|
||||
/// were deleted (the font was saved under a different name) and a trailing backslash escaped
|
||||
/// the style's closing quote.
|
||||
#[test]
|
||||
fn watermark_font_roundtrips_through_the_textpath_style() {
|
||||
for font in ["Georgia", "Bob's Serif", "A;Named:Font", "Quote\"Inside", "Ends with \\", "All \"of\" ;it: 'here\\", "'Serif'", "Trailing'"] {
|
||||
let mut d = doc_with(vec![Paragraph::with_text("Body", CharProps::default())]);
|
||||
d.settings.watermark = Some(Watermark { font: font.into(), ..Default::default() });
|
||||
assert_eq!(rt(&d).settings.watermark.map(|w| w.font), Some(font.into()), "{font:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #3994: the written textpath style stays well-formed CSS whatever the font name: control
|
||||
/// characters are dropped (no font name carries one) and a backslash or quote is a CSS escape
|
||||
/// (`\5c `, `\22 `) instead of escaping the string's closing quote.
|
||||
#[test]
|
||||
fn watermark_font_name_writes_wellformed_css() {
|
||||
let mut d = doc_with(vec![Paragraph::with_text("Body", CharProps::default())]);
|
||||
d.settings.watermark = Some(Watermark { font: "Ends with \\ and \n\"quoted\"".into(), ..Default::default() });
|
||||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||||
let hdr = part_xml(&bytes, "word/header1.xml");
|
||||
let style = hdr.split("v:textpath style=\"").nth(1).and_then(|s| s.split('"').next()).unwrap_or_default();
|
||||
assert!(!style.contains('\n'), "control characters are dropped: {style}");
|
||||
assert!(style.contains(r#"font-family:"Ends with \5c and \22 quoted\22 ";"#), "CSS escapes: {style}");
|
||||
}
|
||||
|
||||
/// #1290: a document without headers gains one default header holding only the watermark (an
|
||||
/// empty paragraph once read back); saving again neither adds another nor duplicates the shape.
|
||||
#[test]
|
||||
|
||||
@@ -1090,7 +1090,7 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
|
||||
out.push((l, false));
|
||||
}
|
||||
}
|
||||
let list = pages.iter().map(|p| p.to_string()).collect::<Vec<_>>().join(", ");
|
||||
let list = pages.iter().map(ToString::to_string).collect::<Vec<_>>().join(", ");
|
||||
out.push(if kind.starts_with("INDEX") { (format!("{e}, {list}"), true) } else { (format!("{e}\t{list}"), false) });
|
||||
}
|
||||
out
|
||||
|
||||
@@ -579,27 +579,8 @@ fn info(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(describe(s, &r))
|
||||
}
|
||||
|
||||
fn properties(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let r = target(s, v)?;
|
||||
let mut c = r.control.clone();
|
||||
let mut content: Option<String> = None;
|
||||
if let Some(t) = short(v, "title") {
|
||||
c.title = t;
|
||||
}
|
||||
if let Some(t) = short(v, "tag") {
|
||||
c.tag = t;
|
||||
}
|
||||
let (no_delete, no_edit) = (p::bool(v, "lockDelete").unwrap_or(!c.lock.can_delete()), p::bool(v, "lockEdit").unwrap_or(!c.lock.can_edit()));
|
||||
c.lock = ControlLock::from_flags(no_delete, no_edit);
|
||||
if let Some(t) = p::bool(v, "temporary") {
|
||||
c.temporary = t;
|
||||
}
|
||||
if let Some(t) = short(v, "placeholder").filter(|t| !t.trim().is_empty()) {
|
||||
if c.showing_placeholder && t != c.placeholder_shown() {
|
||||
content = Some(t.clone());
|
||||
}
|
||||
c.placeholder_text = t;
|
||||
}
|
||||
fn apply_kind_properties(c: &mut ContentControl, v: &Value) -> Option<String> {
|
||||
let mut content = None;
|
||||
match &mut c.kind {
|
||||
ControlKind::Text { multi_line } => {
|
||||
if let Some(m) = p::bool(v, "multiLine") {
|
||||
@@ -633,6 +614,33 @@ fn properties(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
content
|
||||
}
|
||||
|
||||
fn properties(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let r = target(s, v)?;
|
||||
let mut c = r.control.clone();
|
||||
let mut content: Option<String> = None;
|
||||
if let Some(t) = short(v, "title") {
|
||||
c.title = t;
|
||||
}
|
||||
if let Some(t) = short(v, "tag") {
|
||||
c.tag = t;
|
||||
}
|
||||
let (no_delete, no_edit) = (p::bool(v, "lockDelete").unwrap_or(!c.lock.can_delete()), p::bool(v, "lockEdit").unwrap_or(!c.lock.can_edit()));
|
||||
c.lock = ControlLock::from_flags(no_delete, no_edit);
|
||||
if let Some(t) = p::bool(v, "temporary") {
|
||||
c.temporary = t;
|
||||
}
|
||||
if let Some(t) = short(v, "placeholder").filter(|t| !t.trim().is_empty()) {
|
||||
if c.showing_placeholder && t != c.placeholder_shown() {
|
||||
content = Some(t.clone());
|
||||
}
|
||||
c.placeholder_text = t;
|
||||
}
|
||||
if let Some(kc) = apply_kind_properties(&mut c, v) {
|
||||
content = Some(kc);
|
||||
}
|
||||
if let Some(text) = content {
|
||||
let base = content_base(s, &r);
|
||||
let props = if c.showing_placeholder { placeholder_props(s, &base) } else { base };
|
||||
|
||||
@@ -246,7 +246,7 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
|
||||
crate::io::open_path_in(std::path::Path::new(path), password, &s.region()).map_err(CmdError::Failed)?
|
||||
};
|
||||
// Plain text and Markdown carry no language: they are in the editing language.
|
||||
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
|
||||
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase();
|
||||
if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref())
|
||||
&& matches!(ext.as_str(), "txt" | "text" | "md" | "markdown")
|
||||
{
|
||||
|
||||
@@ -601,6 +601,42 @@ fn convert(src: &str, mode: &str, para_start: bool) -> String {
|
||||
convert_chars(src, mode, para_start, &turkic).concat()
|
||||
}
|
||||
|
||||
fn convert_title_char(c: char, i: usize, up: &mut bool, upper: impl Fn(char) -> String, lower: impl Fn(usize, char) -> String) -> String {
|
||||
let n = if *up && c.is_alphabetic() {
|
||||
*up = false;
|
||||
upper(c)
|
||||
} else {
|
||||
lower(i, c)
|
||||
};
|
||||
if c.is_whitespace() {
|
||||
*up = true;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn convert_sentence_char(
|
||||
c: char,
|
||||
i: usize,
|
||||
up: &mut bool,
|
||||
chars: &[char],
|
||||
upper: impl Fn(char) -> String,
|
||||
lower: impl Fn(usize, char) -> String,
|
||||
) -> String {
|
||||
let n = if *up && c.is_alphabetic() {
|
||||
*up = false;
|
||||
upper(c)
|
||||
} else {
|
||||
lower(i, c)
|
||||
};
|
||||
let is_decimal = c == '.'
|
||||
&& i.checked_sub(1).and_then(|j| chars.get(j)).is_some_and(char::is_ascii_digit)
|
||||
&& chars.get(i + 1).is_some_and(char::is_ascii_digit);
|
||||
if matches!(c, '!' | '?') || (c == '.' && !is_decimal) {
|
||||
*up = true;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// The case-converted form of each character of `src`, in order (one entry per char, so a
|
||||
/// change of length such as ß → SS or İ → i̇ can be applied run by run). `turkic` is one flag
|
||||
/// per char: true when that character's run is Turkish, Azerbaijani or Crimean Tatar.
|
||||
@@ -634,34 +670,8 @@ fn convert_chars(src: &str, mode: &str, para_start: bool, turkic: &[bool]) -> Ve
|
||||
upper(c)
|
||||
}
|
||||
}
|
||||
"title" => {
|
||||
let n = if up && c.is_alphabetic() {
|
||||
up = false;
|
||||
upper(c)
|
||||
} else {
|
||||
lower(i, c)
|
||||
};
|
||||
if c.is_whitespace() {
|
||||
up = true;
|
||||
}
|
||||
n
|
||||
}
|
||||
_ => {
|
||||
// Sentence case.
|
||||
let n = if up && c.is_alphabetic() {
|
||||
up = false;
|
||||
upper(c)
|
||||
} else {
|
||||
lower(i, c)
|
||||
};
|
||||
let is_decimal = c == '.'
|
||||
&& i.checked_sub(1).and_then(|j| chars.get(j)).is_some_and(|p| p.is_ascii_digit())
|
||||
&& chars.get(i + 1).is_some_and(|n| n.is_ascii_digit());
|
||||
if matches!(c, '!' | '?') || (c == '.' && !is_decimal) {
|
||||
up = true;
|
||||
}
|
||||
n
|
||||
}
|
||||
"title" => convert_title_char(c, i, &mut up, upper, lower),
|
||||
_ => convert_sentence_char(c, i, &mut up, &chars, upper, lower),
|
||||
};
|
||||
out.push(new);
|
||||
}
|
||||
@@ -756,28 +766,6 @@ pub fn state(s: &Session) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::convert;
|
||||
|
||||
#[test]
|
||||
fn case_conversions() {
|
||||
assert_eq!(convert("hello world. again", "sentence", true), "Hello world. Again");
|
||||
assert_eq!(convert("hello WORLD", "title", true), "Hello World");
|
||||
assert_eq!(convert("Hello", "toggle", true), "hELLO");
|
||||
assert_eq!(convert("straße", "upper", true), "STRASSE");
|
||||
assert_eq!(convert("hello world", "title", false), "Hello World");
|
||||
assert_eq!(convert("ΣΟΦΟΣ ΟΔΟΣ", "lower", true), "σοφος οδος");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sentence_case_ignores_decimal_points() {
|
||||
// #3485: A period between two digits is a decimal point, not a sentence boundary.
|
||||
assert_eq!(convert("one 3.14 apples and two 2.5 pears.", "sentence", true), "One 3.14 apples and two 2.5 pears.");
|
||||
assert_eq!(convert("pi is 3.14. next sentence.", "sentence", true), "Pi is 3.14. Next sentence.");
|
||||
}
|
||||
}
|
||||
|
||||
/// What Enclose Characters works on: the selected text (one full-width or up to two half-width
|
||||
/// characters), or enclosed characters already there (selected, or beside the caret).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -884,3 +872,25 @@ fn enclose(s: &mut Session, v: &Value) -> CmdResult {
|
||||
s.sel = crate::Selection { anchor: at, focus: end };
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::convert;
|
||||
|
||||
#[test]
|
||||
fn case_conversions() {
|
||||
assert_eq!(convert("hello world. again", "sentence", true), "Hello world. Again");
|
||||
assert_eq!(convert("hello WORLD", "title", true), "Hello World");
|
||||
assert_eq!(convert("Hello", "toggle", true), "hELLO");
|
||||
assert_eq!(convert("straße", "upper", true), "STRASSE");
|
||||
assert_eq!(convert("hello world", "title", false), "Hello World");
|
||||
assert_eq!(convert("ΣΟΦΟΣ ΟΔΟΣ", "lower", true), "σοφος οδος");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sentence_case_ignores_decimal_points() {
|
||||
// #3485: A period between two digits is a decimal point, not a sentence boundary.
|
||||
assert_eq!(convert("one 3.14 apples and two 2.5 pears.", "sentence", true), "One 3.14 apples and two 2.5 pears.");
|
||||
assert_eq!(convert("pi is 3.14. next sentence.", "sentence", true), "Pi is 3.14. Next sentence.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -979,6 +979,73 @@ fn cover_page(s: &mut Session, v: &Value) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Why Insert › Drop Cap is off at the caret, as in Word: in a table cell, in an empty paragraph
|
||||
/// and in one that starts with a space.
|
||||
fn drop_cap_off(s: &Session) -> Option<&'static str> {
|
||||
if s.sel.focus.path.cell().is_some() {
|
||||
return Some("drop caps can't go in a table");
|
||||
}
|
||||
let first = s.doc.para_at(&s.sel.focus).and_then(|p| p.text.chars().next());
|
||||
match first {
|
||||
None => Some("the paragraph is empty"),
|
||||
Some(c) if c.is_whitespace() => Some("the paragraph starts with a space"),
|
||||
Some(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert › Drop Cap and Drop Cap Options: none, or the letter dropped into the text or hung in
|
||||
/// the margin, `lines` tall, `distance` points from the text, in `font` (unset: the text's).
|
||||
/// None takes all of it away, the letter's font with it.
|
||||
fn drop_cap(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let lines = v.get("lines").and_then(Value::as_f64).filter(|l| l.is_finite());
|
||||
let none = p::str(v, "position") == Some("none") || lines == Some(0.0);
|
||||
let margin = p::str(v, "position") == Some("margin");
|
||||
let lines = lines.map_or(3.0, |l| l.round().clamp(1.0, 10.0)) as u8;
|
||||
let distance = p::f32(v, "distance").map_or(0.0, |d| d.clamp(0.0, wordcraft_doc::resolve::MAX_DROP_CAP_DISTANCE));
|
||||
// A family name, not a paragraph of text.
|
||||
let font = p::str(v, "font").map(str::trim).filter(|f| !f.is_empty()).map(|f| f.chars().take(64).collect::<String>());
|
||||
super::para::fmt(s, &|p| {
|
||||
p.drop_cap = (!none).then_some(lines);
|
||||
p.drop_cap_margin = (!none && margin).then_some(true);
|
||||
p.drop_cap_distance = (!none && distance > 0.0).then_some(distance);
|
||||
p.drop_cap_font = font.clone().filter(|_| !none);
|
||||
})
|
||||
}
|
||||
|
||||
/// Insert › Drop Cap at the caret: the menu's highlight and the Drop Cap dialog's values.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DropCapState {
|
||||
/// `none`, `dropped` or `margin`.
|
||||
pub position: &'static str,
|
||||
/// Lines to drop (3 when there is no drop cap).
|
||||
pub lines: u8,
|
||||
/// Distance from the text, points.
|
||||
pub distance: f32,
|
||||
/// The letter's own font, if it has one.
|
||||
pub font: Option<String>,
|
||||
/// The font of the paragraph's first character.
|
||||
pub text_font: String,
|
||||
}
|
||||
|
||||
pub fn drop_cap_state(s: &Session) -> DropCapState {
|
||||
let para = s.doc.para_at(&s.sel.focus);
|
||||
let props = para.map(|p| p.props.clone()).unwrap_or_default();
|
||||
let rp = s.doc.styles.resolve_para(&props);
|
||||
let first = para.map(|p| p.props_at(0).clone()).unwrap_or_default();
|
||||
let text_font = s.doc.styles.resolve_char_in(props.style.as_deref(), None, &first).font;
|
||||
DropCapState {
|
||||
position: match (rp.drop_cap, rp.drop_cap_margin) {
|
||||
(0, _) => "none",
|
||||
(_, true) => "margin",
|
||||
_ => "dropped",
|
||||
},
|
||||
lines: if rp.drop_cap == 0 { 3 } else { rp.drop_cap },
|
||||
distance: if rp.drop_cap == 0 { 0.0 } else { rp.drop_cap_distance },
|
||||
font: rp.drop_cap_font.filter(|_| rp.drop_cap > 0),
|
||||
text_font,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1308,70 +1375,3 @@ mod tests {
|
||||
assert_eq!(date_picture("DATE"), None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Why Insert › Drop Cap is off at the caret, as in Word: in a table cell, in an empty paragraph
|
||||
/// and in one that starts with a space.
|
||||
fn drop_cap_off(s: &Session) -> Option<&'static str> {
|
||||
if s.sel.focus.path.cell().is_some() {
|
||||
return Some("drop caps can't go in a table");
|
||||
}
|
||||
let first = s.doc.para_at(&s.sel.focus).and_then(|p| p.text.chars().next());
|
||||
match first {
|
||||
None => Some("the paragraph is empty"),
|
||||
Some(c) if c.is_whitespace() => Some("the paragraph starts with a space"),
|
||||
Some(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert › Drop Cap and Drop Cap Options: none, or the letter dropped into the text or hung in
|
||||
/// the margin, `lines` tall, `distance` points from the text, in `font` (unset: the text's).
|
||||
/// None takes all of it away, the letter's font with it.
|
||||
fn drop_cap(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let lines = v.get("lines").and_then(Value::as_f64).filter(|l| l.is_finite());
|
||||
let none = p::str(v, "position") == Some("none") || lines == Some(0.0);
|
||||
let margin = p::str(v, "position") == Some("margin");
|
||||
let lines = lines.map_or(3.0, |l| l.round().clamp(1.0, 10.0)) as u8;
|
||||
let distance = p::f32(v, "distance").map_or(0.0, |d| d.clamp(0.0, wordcraft_doc::resolve::MAX_DROP_CAP_DISTANCE));
|
||||
// A family name, not a paragraph of text.
|
||||
let font = p::str(v, "font").map(str::trim).filter(|f| !f.is_empty()).map(|f| f.chars().take(64).collect::<String>());
|
||||
super::para::fmt(s, &|p| {
|
||||
p.drop_cap = (!none).then_some(lines);
|
||||
p.drop_cap_margin = (!none && margin).then_some(true);
|
||||
p.drop_cap_distance = (!none && distance > 0.0).then_some(distance);
|
||||
p.drop_cap_font = font.clone().filter(|_| !none);
|
||||
})
|
||||
}
|
||||
|
||||
/// Insert › Drop Cap at the caret: the menu's highlight and the Drop Cap dialog's values.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DropCapState {
|
||||
/// `none`, `dropped` or `margin`.
|
||||
pub position: &'static str,
|
||||
/// Lines to drop (3 when there is no drop cap).
|
||||
pub lines: u8,
|
||||
/// Distance from the text, points.
|
||||
pub distance: f32,
|
||||
/// The letter's own font, if it has one.
|
||||
pub font: Option<String>,
|
||||
/// The font of the paragraph's first character.
|
||||
pub text_font: String,
|
||||
}
|
||||
|
||||
pub fn drop_cap_state(s: &Session) -> DropCapState {
|
||||
let para = s.doc.para_at(&s.sel.focus);
|
||||
let props = para.map(|p| p.props.clone()).unwrap_or_default();
|
||||
let rp = s.doc.styles.resolve_para(&props);
|
||||
let first = para.map(|p| p.props_at(0).clone()).unwrap_or_default();
|
||||
let text_font = s.doc.styles.resolve_char_in(props.style.as_deref(), None, &first).font;
|
||||
DropCapState {
|
||||
position: match (rp.drop_cap, rp.drop_cap_margin) {
|
||||
(0, _) => "none",
|
||||
(_, true) => "margin",
|
||||
_ => "dropped",
|
||||
},
|
||||
lines: if rp.drop_cap == 0 { 3 } else { rp.drop_cap },
|
||||
distance: if rp.drop_cap == 0 { 0.0 } else { rp.drop_cap_distance },
|
||||
font: rp.drop_cap_font.filter(|_| rp.drop_cap > 0),
|
||||
text_font,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -524,7 +524,16 @@ fn insert_field(s: &mut Session, instr: &str, placeholder: &str) -> CmdResult {
|
||||
/// Parse CSV (RFC 4180-ish: quotes, doubled quotes, commas/semicolons/tabs).
|
||||
pub fn parse_csv(text: &str) -> Vec<Vec<String>> {
|
||||
let first = text.lines().next().unwrap_or("");
|
||||
let sep = [',', ';', '\t'].into_iter().max_by_key(|c| first.matches(*c).count()).unwrap_or(',');
|
||||
let mut quoted = false;
|
||||
let mut unquoted = String::new();
|
||||
for c in first.chars() {
|
||||
if c == '"' {
|
||||
quoted = !quoted;
|
||||
} else if !quoted {
|
||||
unquoted.push(c);
|
||||
}
|
||||
}
|
||||
let sep = [',', ';', '\t'].into_iter().max_by_key(|c| unquoted.matches(*c).count()).unwrap_or(',');
|
||||
let mut rows = Vec::new();
|
||||
let mut row = Vec::new();
|
||||
let mut cell = String::new();
|
||||
@@ -837,7 +846,7 @@ fn condition<'a>(s: &Session, t: &'a str, row: usize) -> (bool, Vec<&'a str>) {
|
||||
let inner = t.split_once('{').and_then(|(_, r)| r.split('}').next()).unwrap_or("");
|
||||
let field = mergefield_name(inner).unwrap_or_default();
|
||||
let rest = t.split_once('}').map(|(_, r)| r).unwrap_or("");
|
||||
let op: String = rest.trim_start().chars().take_while(|c| matches!(c, '=' | '<' | '>')).collect();
|
||||
let op: String = rest.trim_start().chars().take_while(|c| matches!(c, '<'..='>')).collect();
|
||||
let mut quoted = rest.split('"').skip(1).step_by(2);
|
||||
let cmp = quoted.next().unwrap_or("");
|
||||
let v = value(s, row, &field);
|
||||
@@ -1180,13 +1189,24 @@ fn envelope_size(v: &Value) -> Result<(f32, f32), CmdError> {
|
||||
.ok_or_else(|| CmdError::Params(format!("unknown envelope size `{name}`")))
|
||||
}
|
||||
|
||||
/// Longest label or envelope address text accepted.
|
||||
const MAX_LABEL_TEXT: usize = 2_000;
|
||||
|
||||
/// An optional label/envelope text parameter, rejected when longer than `MAX_LABEL_TEXT` characters.
|
||||
fn label_text(v: &Value, key: &str) -> Result<Option<String>, CmdError> {
|
||||
match p::str(v, key) {
|
||||
Some(t) if t.chars().count() > MAX_LABEL_TEXT => Err(CmdError::Params(format!("`{key}` is longer than {MAX_LABEL_TEXT} characters"))),
|
||||
t => Ok(t.map(str::to_string)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Envelopes: a landscape page with the return address at the top left and the delivery address
|
||||
/// in the middle. As a new document, or (`add`) as a section of its own at the start of this one.
|
||||
fn envelopes(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let delivery = p::str(v, "delivery").map(str::to_string).unwrap_or_else(|| {
|
||||
let delivery = label_text(v, "delivery")?.unwrap_or_else(|| {
|
||||
if s.merge.rows.is_empty() { "Recipient Name\nStreet Address\nCity, ST 00000".into() } else { address_block(s, s.merge.record) }
|
||||
});
|
||||
let ret = p::str(v, "return").unwrap_or("").to_string();
|
||||
let ret = label_text(v, "return")?.unwrap_or_default();
|
||||
let (w, h) = envelope_size(v)?;
|
||||
let sect = SectionProps {
|
||||
page_w: w,
|
||||
@@ -1408,7 +1428,7 @@ fn labels(s: &mut Session, v: &Value) -> CmdResult {
|
||||
(at("row").min(rows), at("col").min(cols))
|
||||
});
|
||||
let from_rec = single.is_none() && p::bool(v, "fromRecipients").unwrap_or(!s.merge.rows.is_empty() && p::str(v, "text").is_none());
|
||||
let text = p::str(v, "text").unwrap_or("Name\nAddress\nCity, ST 00000").to_string();
|
||||
let text = label_text(v, "text")?.unwrap_or_else(|| "Name\nAddress\nCity, ST 00000".into());
|
||||
let (page_w, page_h) = if prod.paper == "A4" { (595.3, 841.9) } else { (612.0, 792.0) };
|
||||
let h_gap = (prod.h_pitch - prod.width).max(0.0);
|
||||
let v_gap = (prod.v_pitch - prod.height).max(0.0);
|
||||
@@ -1481,6 +1501,12 @@ mod tests {
|
||||
assert_eq!(parse_csv("a;b\n1;2")[1], vec!["1", "2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_delimiter_ignores_quoted_text() {
|
||||
let r = parse_csv("\"Name, first, last\";City\nAnn;Paris\n");
|
||||
assert_eq!(r, vec![vec!["Name, first, last", "City"], vec!["Ann", "Paris"]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_end_to_end() {
|
||||
let mut s = Session::new(Document::new());
|
||||
@@ -1723,6 +1749,17 @@ mod tests {
|
||||
assert!(!s.doc.parts.contains_key(&hdr), "the template header is replaced by the letters' copies");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_and_envelope_text_is_capped() {
|
||||
let mut s = Session::new(Document::new());
|
||||
let long = "x".repeat(2001);
|
||||
assert!(matches!(s.run("mailings.labels", &json!({"text": long})), Err(CmdError::Params(_))));
|
||||
assert!(matches!(s.run("mailings.envelopes", &json!({"delivery": long})), Err(CmdError::Params(_))));
|
||||
assert!(matches!(s.run("mailings.envelopes", &json!({"return": long})), Err(CmdError::Params(_))));
|
||||
s.run("mailings.labels", &json!({"text": "x".repeat(2000), "rows": 40, "cols": 10})).unwrap();
|
||||
assert!(s.doc.plain_text(StoryRef::Body).chars().count() <= 400 * 2000 + 400 * 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelopes_and_labels() {
|
||||
let mut s = Session::new(Document::new());
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! Line Only, Show Text Formatting. What's collapsed and shown is view state ([`OutlineState`]),
|
||||
//! never saved in the document; level changes and moves are ordinary undoable edits.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -464,10 +464,20 @@ pub(crate) fn move_shown(s: &mut Session, up: bool) -> Option<CmdResult> {
|
||||
/// text; one whose text changed in place stays collapsed), and in Outline view a caret the
|
||||
/// outline doesn't show (in collapsed text, or past a body paragraph's first line) moves on to
|
||||
/// the nearest place it does, in the direction it was going.
|
||||
pub(crate) fn after_command(s: &mut Session, before: &Selection) {
|
||||
if !s.view.outline.collapsed.is_empty() {
|
||||
/// `changed` says whether the document changed (its revision moved); if not, nothing moved.
|
||||
pub(crate) fn after_command(s: &mut Session, before: &Selection, changed: bool) {
|
||||
if changed && !s.view.outline.collapsed.is_empty() {
|
||||
let hashes: Vec<Option<u64>> = (0..s.doc.body.len()).map(|i| text_hash(&s.doc, i)).collect();
|
||||
let levels = ol::levels(&s.doc);
|
||||
// The headings not yet taken, by text hash.
|
||||
let mut by_hash: HashMap<u64, BTreeSet<usize>> = HashMap::new();
|
||||
for (j, h) in hashes.iter().enumerate() {
|
||||
if let Some(h) = h
|
||||
&& levels.get(j).is_some_and(|l| *l < BODY)
|
||||
{
|
||||
by_hash.entry(*h).or_default().insert(j);
|
||||
}
|
||||
}
|
||||
let mut used = BTreeSet::new();
|
||||
let mut out = Vec::new();
|
||||
for (i, h) in std::mem::take(&mut s.view.outline.collapsed) {
|
||||
@@ -476,10 +486,22 @@ pub(crate) fn after_command(s: &mut Session, before: &Selection) {
|
||||
let found = if free(i, &used) && same(i) {
|
||||
Some(i)
|
||||
} else {
|
||||
(0..levels.len()).filter(|j| free(*j, &used) && same(*j)).min_by_key(|j| j.abs_diff(i)).or_else(|| free(i, &used).then_some(i))
|
||||
// The nearest free heading with the same text (the earlier one on a tie).
|
||||
let nearest = by_hash.get(&h).and_then(|set| {
|
||||
let below = set.range(..=i).next_back().copied();
|
||||
let above = set.range(i..).next().copied();
|
||||
match (below, above) {
|
||||
(Some(b), Some(a)) => Some(if a.abs_diff(i) < b.abs_diff(i) { a } else { b }),
|
||||
(b, a) => b.or(a),
|
||||
}
|
||||
});
|
||||
nearest.or_else(|| free(i, &used).then_some(i))
|
||||
};
|
||||
if let Some(j) = found {
|
||||
used.insert(j);
|
||||
if let Some(set) = hashes.get(j).copied().flatten().and_then(|hj| by_hash.get_mut(&hj)) {
|
||||
set.remove(&j);
|
||||
}
|
||||
out.push((j, hashes.get(j).copied().flatten().unwrap_or(h)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +228,50 @@ enum Tok {
|
||||
Cmp(&'static str),
|
||||
}
|
||||
|
||||
fn parse_num_token(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Result<Tok, CmdError> {
|
||||
let mut lit = String::new();
|
||||
while let Some(&d) = chars.peek() {
|
||||
if d.is_ascii_digit() || d == '.' {
|
||||
lit.push(d);
|
||||
chars.next();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let x = lit.parse::<f64>().map_err(|_| syntax(format!("`{lit}` isn't a number")))?;
|
||||
Ok(Tok::Num(x))
|
||||
}
|
||||
|
||||
fn parse_name_token(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Tok {
|
||||
let mut name = String::new();
|
||||
while let Some(&d) = chars.peek() {
|
||||
if d.is_ascii_alphanumeric() {
|
||||
name.push(d.to_ascii_uppercase());
|
||||
chars.next();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Tok::Name(name)
|
||||
}
|
||||
|
||||
fn parse_cmp_token(ch: char, chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Tok {
|
||||
chars.next();
|
||||
let next = chars.peek().copied();
|
||||
let (op, longer) = match (ch, next) {
|
||||
('<', Some('=')) => ("<=", true),
|
||||
('<', Some('>')) => ("<>", true),
|
||||
('>', Some('=')) => (">=", true),
|
||||
('<', _) => ("<", false),
|
||||
('>', _) => (">", false),
|
||||
_ => ("=", false),
|
||||
};
|
||||
if longer {
|
||||
chars.next();
|
||||
}
|
||||
Tok::Cmp(op)
|
||||
}
|
||||
|
||||
fn tokenize(s: &str) -> Result<Vec<Tok>, CmdError> {
|
||||
let mut out = Vec::new();
|
||||
let mut chars = s.chars().peekable();
|
||||
@@ -235,46 +279,14 @@ fn tokenize(s: &str) -> Result<Vec<Tok>, CmdError> {
|
||||
if ch.is_whitespace() {
|
||||
chars.next();
|
||||
} else if ch.is_ascii_digit() || ch == '.' {
|
||||
let mut lit = String::new();
|
||||
while let Some(&d) = chars.peek() {
|
||||
if d.is_ascii_digit() || d == '.' {
|
||||
lit.push(d);
|
||||
chars.next();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let x = lit.parse::<f64>().map_err(|_| syntax(format!("`{lit}` isn't a number")))?;
|
||||
out.push(Tok::Num(x));
|
||||
out.push(parse_num_token(&mut chars)?);
|
||||
} else if ch.is_ascii_alphabetic() {
|
||||
let mut name = String::new();
|
||||
while let Some(&d) = chars.peek() {
|
||||
if d.is_ascii_alphanumeric() {
|
||||
name.push(d.to_ascii_uppercase());
|
||||
chars.next();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.push(Tok::Name(name));
|
||||
out.push(parse_name_token(&mut chars));
|
||||
} else if matches!(ch, '+' | '-' | '*' | '/' | '^' | '(' | ')' | ',' | ':') {
|
||||
out.push(Tok::Op(ch));
|
||||
chars.next();
|
||||
} else if matches!(ch, '<' | '>' | '=') {
|
||||
chars.next();
|
||||
let next = chars.peek().copied();
|
||||
let (op, longer) = match (ch, next) {
|
||||
('<', Some('=')) => ("<=", true),
|
||||
('<', Some('>')) => ("<>", true),
|
||||
('>', Some('=')) => (">=", true),
|
||||
('<', _) => ("<", false),
|
||||
('>', _) => (">", false),
|
||||
_ => ("=", false),
|
||||
};
|
||||
if longer {
|
||||
chars.next();
|
||||
}
|
||||
out.push(Tok::Cmp(op));
|
||||
} else if matches!(ch, '<'..='>') {
|
||||
out.push(parse_cmp_token(ch, &mut chars));
|
||||
} else {
|
||||
return Err(syntax(format!("`{ch}` isn't part of a formula")));
|
||||
}
|
||||
@@ -468,7 +480,7 @@ impl Parser {
|
||||
|
||||
/// `B4` → (row 3, column 1): the row counts from 1 and the column from A.
|
||||
fn cell_ref(name: &str) -> Option<(usize, usize)> {
|
||||
let letters: String = name.chars().take_while(|c| c.is_ascii_alphabetic()).collect();
|
||||
let letters: String = name.chars().take_while(char::is_ascii_alphabetic).collect();
|
||||
let digits = name.get(letters.len()..)?;
|
||||
if letters.is_empty() || digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
|
||||
return None;
|
||||
|
||||
@@ -195,8 +195,7 @@ impl AutoCorrectPrefs {
|
||||
.filter(|(a, _)| !self.removed.iter().any(|r| r == a) && !self.entries.iter().any(|(e, _)| e == a))
|
||||
.filter(|(a, _)| self.fractions || !FRACTIONS.contains(a))
|
||||
.find(|(a, _)| {
|
||||
*a == word
|
||||
|| (a.chars().all(|c| c.is_lowercase()) && word.to_lowercase() == *a && word.chars().next().is_some_and(char::is_uppercase))
|
||||
*a == word || (a.chars().all(char::is_lowercase) && word.to_lowercase() == *a && word.chars().next().is_some_and(char::is_uppercase))
|
||||
})
|
||||
.map(|(a, b)| {
|
||||
if *a != word && b.chars().next().is_some_and(char::is_lowercase) {
|
||||
@@ -249,7 +248,7 @@ impl AutoCorrectPrefs {
|
||||
let mut v: Vec<String> = NOT_SENTENCE_END
|
||||
.iter()
|
||||
.filter(|a| !self.exceptions_removed.iter().any(|r| r.eq_ignore_ascii_case(a)))
|
||||
.map(|a| a.to_string())
|
||||
.map(ToString::to_string)
|
||||
.chain(self.exceptions.iter().map(|a| a.to_lowercase()))
|
||||
.collect();
|
||||
v.sort();
|
||||
|
||||
@@ -181,7 +181,7 @@ impl Store {
|
||||
let mut entries = BTreeMap::new();
|
||||
let mut unreadable = false;
|
||||
for dir in self.directories()? {
|
||||
let Some(id) = dir.file_name().and_then(|s| s.to_str()) else { continue };
|
||||
let Some(id) = dir.file_name().and_then(std::ffi::OsStr::to_str) else { continue };
|
||||
let guard = if self.locks.contains_key(id) {
|
||||
None
|
||||
} else {
|
||||
|
||||
@@ -673,6 +673,10 @@ impl Session {
|
||||
}
|
||||
|
||||
pub fn new(doc: Document) -> Self {
|
||||
Self::with_registry(doc, Arc::new(crate::cmd::registry()))
|
||||
}
|
||||
|
||||
fn with_registry(doc: Document, registry: Arc<Registry>) -> Self {
|
||||
let start = doc.start_of(StoryRef::Body);
|
||||
let bib_style = doc_bib_style(&doc);
|
||||
Session {
|
||||
@@ -692,7 +696,7 @@ impl Session {
|
||||
find: FindState::default(),
|
||||
goal_x: None,
|
||||
page_hint: 0,
|
||||
registry: Arc::new(crate::cmd::registry()),
|
||||
registry,
|
||||
author: "WordCraft User".into(),
|
||||
initials: String::new(),
|
||||
painter: None,
|
||||
@@ -876,6 +880,12 @@ impl Session {
|
||||
|
||||
/// The current layout (recomputed when the document or view changed).
|
||||
pub fn layout(&mut self) -> Arc<DocLayout> {
|
||||
let typing = self.typing_word();
|
||||
self.layout_with(typing)
|
||||
}
|
||||
|
||||
/// [`Session::layout`] with `typing` as the word being typed.
|
||||
fn layout_with(&mut self, typing: Option<wordcraft_layout::TypingWord>) -> Arc<DocLayout> {
|
||||
let mode = if self.view.read_mode { ViewMode::Read } else { self.view.mode };
|
||||
let width = match mode {
|
||||
ViewMode::Read => self.view.read_width,
|
||||
@@ -889,7 +899,6 @@ impl Session {
|
||||
let hide_deleted = !self.view.show_markup;
|
||||
let deleted = (hide_deleted, self.prefs.markup.hidden_deletions(), self.revisions_in_balloons());
|
||||
let outline = (mode == ViewMode::Outline).then(|| self.view.outline.view());
|
||||
let typing = self.typing_word();
|
||||
if let Some((r, w, h, m, sp, l, pf, o, hd, n, t)) = &self.layout
|
||||
&& *r == self.rev
|
||||
&& (*w == width || mode == ViewMode::Print)
|
||||
@@ -966,6 +975,41 @@ impl Session {
|
||||
let (start, end) = typed_range(&self.doc.para_at(&at)?.text, at.off)?;
|
||||
Some(TypedWord { at, start, end })
|
||||
}
|
||||
/// The page as command `id` would leave it, for a live preview (Insert › Table's grid, #3931):
|
||||
/// the command runs on a copy of the document, with this caret and view, and the copy is laid
|
||||
/// out. The copy comes back with its layout, which is drawn from it: the layout's paragraph
|
||||
/// paths name the copy's paragraphs, not this document's, and so is the copy's selection,
|
||||
/// which is where the caret would be. The document, the selection, the undo history and Repeat
|
||||
/// are untouched. `None` when the command fails or isn't allowed here.
|
||||
pub fn preview_layout(&mut self, id: &str, params: &Value) -> Option<(Document, Selection, Arc<DocLayout>)> {
|
||||
// The word being typed keeps going without a squiggle on the preview: the command moves
|
||||
// the copy's caret off it and may split its paragraph, so it is found again by its place.
|
||||
let typing = self.typing_word();
|
||||
let at = self.sel.focus.clone();
|
||||
let mut copy = Session::with_registry(self.doc.clone(), self.registry.clone());
|
||||
copy.sel = self.sel.clone();
|
||||
copy.column = self.column.clone();
|
||||
copy.widened = self.widened.clone();
|
||||
copy.view = self.view.clone();
|
||||
copy.prefs = self.prefs.clone();
|
||||
copy.path = self.path.clone();
|
||||
copy.file_name = self.file_name.clone();
|
||||
copy.author = self.author.clone();
|
||||
copy.system_region = self.system_region.clone();
|
||||
copy.editing_lang = self.editing_lang.clone();
|
||||
// The copy shares the paragraph layouts, so only what the command changed is laid out.
|
||||
std::mem::swap(&mut copy.cache, &mut self.cache);
|
||||
let layout = copy.run(id, params).is_ok().then(|| {
|
||||
let typing = typing.and_then(|t| {
|
||||
let p = copy.doc.para_at(&at)?;
|
||||
(t.end <= p.len()).then_some(wordcraft_layout::TypingWord { rev: p.rev, ..t })
|
||||
});
|
||||
copy.layout_with(typing)
|
||||
});
|
||||
std::mem::swap(&mut copy.cache, &mut self.cache);
|
||||
layout.map(|l| (copy.doc, copy.sel, l))
|
||||
}
|
||||
|
||||
/// A layout for output (PDF, images, print): no proofing marks, print view.
|
||||
pub fn export_layout(&self) -> Arc<DocLayout> {
|
||||
self.export_layout_with(false)
|
||||
@@ -1418,7 +1462,7 @@ impl Session {
|
||||
/// [`crate::cmd::protect::Facts`] for the document as it is now, cached per revision.
|
||||
fn protect_facts(&self) -> crate::cmd::protect::Facts {
|
||||
let key = (self.document_id, self.rev, self.author.clone());
|
||||
let mut cache = self.protect_facts.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut cache = self.protect_facts.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some((k, f)) = cache.as_ref()
|
||||
&& *k == key
|
||||
{
|
||||
@@ -1527,11 +1571,12 @@ impl Session {
|
||||
let default_language_before = self.prefs.default_language.clone();
|
||||
let run = spec.run;
|
||||
let mutates = spec.mutates;
|
||||
let rev_before = self.rev;
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::cmd::column::dispatch(self, id, mutates, run, params)));
|
||||
let mut result = match result {
|
||||
Ok(r) => r,
|
||||
Err(p) => {
|
||||
let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
|
||||
let msg = p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
|
||||
Err(CmdError::Failed(format!("{id} panicked: {msg}")))
|
||||
}
|
||||
};
|
||||
@@ -1571,7 +1616,7 @@ impl Session {
|
||||
self.note_edit();
|
||||
}
|
||||
// Outline view: collapsed headings follow edits; the caret stays where it shows.
|
||||
crate::cmd::outline::after_command(self, &sel_before);
|
||||
crate::cmd::outline::after_command(self, &sel_before, self.rev != rev_before);
|
||||
// Extra selected objects last until something else is edited or selected; formatting
|
||||
// them keeps them selected, as in Word.
|
||||
if !matches!(id, "select.addObject" | "arrange.ungroup" | "shape.fill" | "shape.effects") && (spec.mutates || self.sel != sel_before)
|
||||
|
||||
@@ -1674,6 +1674,73 @@ fn tables_commands() {
|
||||
assert!(s.run("table.merge", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// #3931: the page as a command would leave it, laid out without running it on the document:
|
||||
/// Insert › Table's grid previews the table it would insert. The document, the caret, the undo
|
||||
/// history and Repeat stay as they were.
|
||||
#[test]
|
||||
fn preview_layout_shows_a_command_without_running_it() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Lanterns"));
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
let (doc, sel, rev) = (s.doc.clone(), s.sel.clone(), s.rev());
|
||||
let cells = |l: &wordcraft_layout::DocLayout| l.index.keys().filter(|(st, p)| *st == StoryRef::Body && p.0.len() == 4).count();
|
||||
let (_, _, preview) = s.preview_layout("insert.table", &json!({"rows": 4, "cols": 3})).expect("a table can be inserted here");
|
||||
assert_eq!(cells(&preview), 12, "four rows of three cells");
|
||||
// At the caret: after "Lanterns", the second block.
|
||||
let tables: std::collections::BTreeSet<u32> =
|
||||
preview.index.keys().filter(|(st, p)| *st == StoryRef::Body && p.0.len() == 4).map(|(_, p)| p.0[0]).collect();
|
||||
assert_eq!(tables, [1].into(), "the table goes where the caret is");
|
||||
assert_eq!(s.doc, doc);
|
||||
assert_eq!((&s.sel, s.rev(), s.dirty), (&sel, rev, false));
|
||||
assert!(!s.can_undo() && s.last_command.is_none());
|
||||
assert_eq!(cells(&s.layout()), 0, "the page itself has no table");
|
||||
assert!(s.preview_layout("no.such", &json!({})).is_none());
|
||||
}
|
||||
|
||||
/// #3931 review: the preview comes back with the document it was laid out from. The table moves
|
||||
/// the paragraphs after the caret down a block, so this document would draw the layout's
|
||||
/// paragraphs from the wrong blocks, or from none.
|
||||
#[test]
|
||||
fn preview_layout_comes_with_the_document_it_lays_out() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Lanterns\nKites"));
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
run(&mut s, "caret.end", json!({}));
|
||||
let (doc, _, preview) = s.preview_layout("insert.table", &json!({"rows": 2, "cols": 2})).expect("a table can be inserted here");
|
||||
for (story, path) in preview.index.keys().filter(|(st, _)| *st == StoryRef::Body) {
|
||||
assert!(doc.para(*story, path).is_some(), "{path:?} names a paragraph of the previewed document");
|
||||
}
|
||||
let kites = preview.index.keys().find(|(st, p)| *st == StoryRef::Body && doc.para(*st, p).is_some_and(|p| p.text == "Kites"));
|
||||
assert!(kites.is_some_and(|(_, p)| p.0.len() == 1 && p.0[0] > 1), "Kites is laid out after the table: {kites:?}");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Lanterns\nKites", "the document itself has no table");
|
||||
}
|
||||
|
||||
/// #3931 review: the word being typed has no spelling squiggle on the preview either, though
|
||||
/// the table moves the copy's caret off it and splits its paragraph.
|
||||
#[test]
|
||||
fn preview_layout_keeps_the_word_being_typed_unmarked() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("helo"));
|
||||
s.view.proofing = true;
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": " wrld"}));
|
||||
let marks = |l: &wordcraft_layout::DocLayout, doc: &wordcraft_doc::Document| -> Vec<String> {
|
||||
let text = &doc.para(StoryRef::Body, &wordcraft_doc::Path(vec![0])).unwrap().text;
|
||||
l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|i| match i {
|
||||
wordcraft_layout::Placed::Lines { para, path, .. } if path.0 == [0] => {
|
||||
Some(para.issues.iter().filter(|i| !i.2).map(|i| text[i.0..i.1].to_string()).collect::<Vec<_>>())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.collect()
|
||||
};
|
||||
let doc = s.doc.clone();
|
||||
assert_eq!(marks(&s.layout(), &doc), ["helo"], "the page itself: only the finished word is marked");
|
||||
let (copy, _, preview) = s.preview_layout("insert.table", &json!({"rows": 2, "cols": 2})).unwrap();
|
||||
assert_eq!(marks(&preview, ©), ["helo"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_row_on_a_merge_start_promotes_the_cell_below() {
|
||||
use wordcraft_doc::props::VMerge;
|
||||
@@ -7859,6 +7926,37 @@ fn outline_collapse_hides_descendants_and_expand_restores() {
|
||||
assert_eq!(s.selected_text(), "One\na\nTwo\nb");
|
||||
}
|
||||
|
||||
/// #3621: with thousands of collapsed headings, an Enter above them (which shifts every index)
|
||||
/// finds them again without scanning the document once per heading, and a caret move doesn't
|
||||
/// redo the matching.
|
||||
#[test]
|
||||
fn outline_many_collapsed_headings_stay_fast_after_edits() {
|
||||
const N: usize = 8000;
|
||||
let text: Vec<String> = (0..N).flat_map(|i| [format!("Heading {i}"), format!("body {i}")]).collect();
|
||||
let mut d = wordcraft_doc::Document::from_text(&text.join("\n"));
|
||||
for i in 0..N {
|
||||
let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(2 * i)).unwrap();
|
||||
p.props.style = Some("Heading1".into());
|
||||
}
|
||||
let mut s = Session::new(d);
|
||||
run(&mut s, "view.outline", json!({}));
|
||||
run(&mut s, "outline.showLevel1", json!({}));
|
||||
s.sel = crate::Selection::caret(Pos::body(0, 0));
|
||||
run(&mut s, "outline.collapse", json!({}));
|
||||
let collapsed_texts =
|
||||
|s: &Session| -> Vec<String> { s.view.outline.collapsed.iter().map(|(i, _)| s.doc.body[*i].as_para().unwrap().text.clone()).collect() };
|
||||
let before = collapsed_texts(&s);
|
||||
assert_eq!(before.len(), N, "every heading is collapsed");
|
||||
s.sel = crate::Selection::caret(Pos::body(1, 0));
|
||||
for _ in 0..10 {
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
}
|
||||
for _ in 0..20 {
|
||||
run(&mut s, "caret.right", json!({}));
|
||||
}
|
||||
assert_eq!(collapsed_texts(&s), before, "the same headings stay collapsed");
|
||||
}
|
||||
|
||||
/// #745: layout keeps a picture's size, so pictures read from HTML, Markdown, ODT or RTF at
|
||||
/// their pixel size are fitted to the text width when the file is opened (and when pasted),
|
||||
/// keeping their proportions; small pictures keep their size.
|
||||
|
||||
+30
-29
@@ -99,12 +99,12 @@ pub struct FaceRef(&'static FontFace);
|
||||
impl FaceRef {
|
||||
/// The handle for a loaded face (memoised per face).
|
||||
pub fn of(face: &Arc<FontFace>) -> FaceRef {
|
||||
static LEAKED: RwLock<Vec<Option<&'static FontFace>>> = RwLock::new(Vec::new());
|
||||
static LEAKED: RwLock<Vec<Option<&FontFace>>> = RwLock::new(Vec::new());
|
||||
let id = face.id as usize;
|
||||
if let Some(Some(f)) = LEAKED.read().unwrap_or_else(|e| e.into_inner()).get(id) {
|
||||
if let Some(Some(f)) = LEAKED.read().unwrap_or_else(std::sync::PoisonError::into_inner).get(id) {
|
||||
return FaceRef(f);
|
||||
}
|
||||
let mut w = LEAKED.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut w = LEAKED.write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if w.len() <= id {
|
||||
w.resize(id + 1, None);
|
||||
}
|
||||
@@ -933,7 +933,7 @@ pub fn base_style(style: &str) -> &str {
|
||||
}
|
||||
|
||||
fn norm(s: &str) -> String {
|
||||
s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect()
|
||||
s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
|
||||
}
|
||||
|
||||
/// Weight implied by a style name.
|
||||
@@ -1023,11 +1023,11 @@ impl FontDb {
|
||||
pub fn set_system_fallback(&self, on: bool) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled = on;
|
||||
self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).enabled = on;
|
||||
}
|
||||
// Misses remembered while it was off may be found now.
|
||||
self.fallbacks.write().unwrap_or_else(|e| e.into_inner()).map.clear();
|
||||
*self.run_fallbacks.write().unwrap_or_else(|e| e.into_inner()) = RunFallbackCache::default();
|
||||
self.fallbacks.write().unwrap_or_else(std::sync::PoisonError::into_inner).map.clear();
|
||||
*self.run_fallbacks.write().unwrap_or_else(std::sync::PoisonError::into_inner) = RunFallbackCache::default();
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = on;
|
||||
}
|
||||
@@ -1041,14 +1041,14 @@ impl FontDb {
|
||||
}
|
||||
|
||||
fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
|
||||
self.faces.read().unwrap_or_else(|e| e.into_inner())
|
||||
self.faces.read().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// The system font catalog, scanned first if it hasn't been yet.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_catalog(&self) -> std::sync::RwLockReadGuard<'_, Vec<CatalogEntry>> {
|
||||
self.ensure_catalog();
|
||||
self.catalog.read().unwrap_or_else(|e| e.into_inner())
|
||||
self.catalog.read().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// Family names available (loaded plus cataloged system fonts), sorted and deduplicated. Like
|
||||
@@ -1184,7 +1184,7 @@ impl FontDb {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut faces = self.faces.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut faces = self.faces.write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut added = 0;
|
||||
for f in new {
|
||||
if !faces.iter().any(|g| g.family.eq_ignore_ascii_case(&f.family) && g.style.eq_ignore_ascii_case(&f.style)) {
|
||||
@@ -1243,7 +1243,7 @@ impl FontDb {
|
||||
stack.push(p);
|
||||
continue;
|
||||
}
|
||||
let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
|
||||
let ext = p.extension().and_then(std::ffi::OsStr::to_str).map(|e| e.to_ascii_lowercase());
|
||||
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
|
||||
continue;
|
||||
}
|
||||
@@ -1263,7 +1263,7 @@ impl FontDb {
|
||||
}
|
||||
let n = found.len();
|
||||
log::debug!("cataloged {n} system font faces");
|
||||
*self.catalog.write().unwrap_or_else(|e| e.into_inner()) = found;
|
||||
*self.catalog.write().unwrap_or_else(std::sync::PoisonError::into_inner) = found;
|
||||
self.catalog_gen.fetch_add(1, Ordering::AcqRel);
|
||||
n
|
||||
}
|
||||
@@ -1273,7 +1273,7 @@ impl FontDb {
|
||||
/// its styles. Returns whether any face was added.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_cataloged(&self, family: &str) -> bool {
|
||||
let _loading = self.loading.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _loading = self.loading.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
// Another lookup may have loaded it while this one waited.
|
||||
if self.is_loaded(family) {
|
||||
return true;
|
||||
@@ -1282,7 +1282,7 @@ impl FontDb {
|
||||
let files = paths.iter().filter_map(|p| std::fs::read(p).ok()).map(|b| (b, None)).collect();
|
||||
let added = self.add_fonts(files) > 0;
|
||||
// The fallback searches' index: these files are loaded fonts now.
|
||||
let mut index = self.coverage.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut index = self.coverage.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
for file in index.files.iter_mut().filter(|f| paths.contains(&f.path)) {
|
||||
file.used = true;
|
||||
}
|
||||
@@ -1471,7 +1471,7 @@ impl FontDb {
|
||||
let mut f = make_face(base.bytes.clone(), base.index, base.family.clone(), style.to_string(), base.legacy_family.clone(), coords)?;
|
||||
f.local_names = base.local_names.clone();
|
||||
let f = Arc::new(f);
|
||||
self.faces.write().unwrap_or_else(|e| e.into_inner()).push(f.clone());
|
||||
self.faces.write().unwrap_or_else(std::sync::PoisonError::into_inner).push(f.clone());
|
||||
Some(f)
|
||||
}
|
||||
|
||||
@@ -1540,7 +1540,7 @@ impl FontDb {
|
||||
fn cached_fallback(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
|
||||
let n = self.read_faces().len();
|
||||
{
|
||||
let cache = self.fallbacks.read().unwrap_or_else(|e| e.into_inner());
|
||||
let cache = self.fallbacks.read().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if cache.faces == n
|
||||
&& let Some(hit) = cache.map.get(&(c, exclude))
|
||||
{
|
||||
@@ -1554,7 +1554,7 @@ impl FontDb {
|
||||
if n != self.read_faces().len() {
|
||||
return found;
|
||||
}
|
||||
let mut cache = self.fallbacks.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut cache = self.fallbacks.write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if cache.faces != n || cache.map.len() >= FALLBACK_CACHE_MAX {
|
||||
cache.map.clear();
|
||||
cache.faces = n;
|
||||
@@ -1587,7 +1587,7 @@ impl FontDb {
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let enabled = self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled;
|
||||
let enabled = self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).enabled;
|
||||
if enabled {
|
||||
self.load_cataloged(FAMILY);
|
||||
}
|
||||
@@ -1599,7 +1599,7 @@ impl FontDb {
|
||||
/// one search at a time.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn system_search(&self, c: char, exclude: u32) -> (Option<Arc<FontFace>>, usize) {
|
||||
let _searching = self.searching.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _searching = self.searching.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
// A search that held the lock first may have loaded a font covering `c`: choose among the
|
||||
// loaded faces again before loading more.
|
||||
let (found, n) = self.loaded_fallback(c, exclude);
|
||||
@@ -1660,7 +1660,7 @@ impl FontDb {
|
||||
let key = (set, exclude, bold, italic);
|
||||
let n = self.read_faces().len();
|
||||
{
|
||||
let cache = self.run_fallbacks.read().unwrap_or_else(|e| e.into_inner());
|
||||
let cache = self.run_fallbacks.read().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if cache.faces == n
|
||||
&& let Some(hit) = cache.map.get(&key)
|
||||
{
|
||||
@@ -1671,7 +1671,7 @@ impl FontDb {
|
||||
// meanwhile by another thread (one its search, or a family named by a document) must make
|
||||
// a miss count as stale, not as the answer for the new count.
|
||||
let (found, n) = self.search_run(&key.0, exclude, bold, italic);
|
||||
let mut cache = self.run_fallbacks.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut cache = self.run_fallbacks.write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if cache.faces != n || cache.map.len() >= RUN_FALLBACK_CACHE_MAX || cache.chars.saturating_add(key.0.len()) > RUN_FALLBACK_CACHE_CHARS {
|
||||
cache.map.clear();
|
||||
cache.faces = n;
|
||||
@@ -1750,7 +1750,7 @@ impl FontDb {
|
||||
return false;
|
||||
}
|
||||
{
|
||||
let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let sys = self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if !sys.enabled || sys.misses.contains(&c) {
|
||||
return false;
|
||||
}
|
||||
@@ -1764,7 +1764,7 @@ impl FontDb {
|
||||
if self.is_loaded(fam) || !cataloged.iter().any(|f| f.eq_ignore_ascii_case(fam)) {
|
||||
continue;
|
||||
}
|
||||
if !self.sys.lock().unwrap_or_else(|e| e.into_inner()).tried.insert(fam) {
|
||||
if !self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).tried.insert(fam) {
|
||||
continue;
|
||||
}
|
||||
self.load_cataloged(fam);
|
||||
@@ -1775,7 +1775,7 @@ impl FontDb {
|
||||
if self.indexed_fallback(c) {
|
||||
return true;
|
||||
}
|
||||
let mut sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut sys = self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if sys.misses.len() >= SYS_MISSES_MAX {
|
||||
sys.misses.clear();
|
||||
}
|
||||
@@ -1795,7 +1795,7 @@ impl FontDb {
|
||||
/// before the Regular.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn indexed_run_fallback(&self, chars: &[char]) {
|
||||
if !self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled || chars.iter().any(|c| c.is_control()) {
|
||||
if !self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).enabled || chars.iter().any(|c| c.is_control()) {
|
||||
return;
|
||||
}
|
||||
self.load_indexed(chars, true);
|
||||
@@ -1811,13 +1811,14 @@ impl FontDb {
|
||||
let covered = |db: &FontDb| db.read_faces().iter().any(|f| chars.iter().all(|c| f.covers(*c)));
|
||||
let cps: Vec<u32> = chars.iter().map(|c| u32::from(*c)).collect();
|
||||
let maps_all = |ranges: &[(u32, u32)]| cps.iter().all(|cp| ranges_hold(ranges, *cp));
|
||||
let mut index = self.coverage.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut index = self.coverage.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let generation = self.catalog_gen.load(Ordering::Acquire);
|
||||
if index.generation != Some(generation) {
|
||||
// A new scan (or the first search): keep what is known of files still installed.
|
||||
let mut known: HashMap<std::path::PathBuf, IndexedFile> =
|
||||
std::mem::take(&mut index.files).into_iter().map(|f| (f.path.clone(), f)).collect();
|
||||
let mut paths: Vec<std::path::PathBuf> = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.path.clone()).collect();
|
||||
let mut paths: Vec<std::path::PathBuf> =
|
||||
self.catalog.read().unwrap_or_else(std::sync::PoisonError::into_inner).iter().map(|e| e.path.clone()).collect();
|
||||
paths.sort();
|
||||
paths.dedup();
|
||||
let files = paths
|
||||
@@ -1880,11 +1881,11 @@ impl FontDb {
|
||||
/// Glyph outline in font units, y-down (flipped), cached per (face, glyph).
|
||||
pub fn outline(&self, face: &FontFace, gid: u32) -> Arc<BezPath> {
|
||||
let key = (face.id, gid);
|
||||
if let Some(p) = self.outlines.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
|
||||
if let Some(p) = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
|
||||
return p.clone();
|
||||
}
|
||||
let p = Arc::new(face.glyph_outline(gid));
|
||||
let mut cache = self.outlines.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut cache = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if cache.len() >= OUTLINE_CACHE_MAX {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
@@ -368,7 +368,7 @@ impl FontDb {
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_file())
|
||||
.filter(|p| p.extension().and_then(|e| e.to_str()).is_some_and(|e| STORED_EXTS.contains(&e.to_ascii_lowercase().as_str())))
|
||||
.filter(|p| p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| STORED_EXTS.contains(&e.to_ascii_lowercase().as_str())))
|
||||
.collect();
|
||||
paths.sort();
|
||||
for p in paths {
|
||||
|
||||
@@ -260,7 +260,7 @@ pub fn parse(b: &[u8]) -> Option<MathTable> {
|
||||
pub fn table(face: &FontFace) -> Option<Arc<MathTable>> {
|
||||
static CACHE: OnceLock<Mutex<HashMap<u32, Option<Arc<MathTable>>>>> = OnceLock::new();
|
||||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
if let Some(t) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&face.id()) {
|
||||
if let Some(t) = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&face.id()) {
|
||||
return t.clone();
|
||||
}
|
||||
let t = face
|
||||
@@ -268,7 +268,7 @@ pub fn table(face: &FontFace) -> Option<Arc<MathTable>> {
|
||||
.and_then(|f| f.table_data(skrifa::raw::types::Tag::new(b"MATH")).map(|d| d.as_bytes().to_vec()))
|
||||
.and_then(|bytes| parse(&bytes))
|
||||
.map(Arc::new);
|
||||
cache.lock().unwrap_or_else(|e| e.into_inner()).insert(face.id(), t.clone());
|
||||
cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(face.id(), t.clone());
|
||||
t
|
||||
}
|
||||
|
||||
|
||||
@@ -389,7 +389,7 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved {
|
||||
let cacheable = family.len() <= FAMILY_KEY_MAX;
|
||||
let key = (if cacheable { family.to_string() } else { String::new() }, bold, italic);
|
||||
{
|
||||
let mut c = cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut c = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.0 != generation {
|
||||
*c = (generation, HashMap::new());
|
||||
}
|
||||
@@ -409,7 +409,7 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved {
|
||||
};
|
||||
let face = db.face(&fam, style_name(bold, italic));
|
||||
let r = Resolved { synth_bold: bold && face.weight < 600.0, synth_italic: italic && !face.italic, substituted, face: FaceRef::of(&face) };
|
||||
let mut c = cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut c = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if cacheable && c.0 == generation {
|
||||
if c.1.len() >= FAMILY_CACHE_MAX {
|
||||
c.1.clear();
|
||||
@@ -431,7 +431,7 @@ fn hhea_line(ascender: i16, descender: i16, line_gap: i16) -> (f64, f64) {
|
||||
pub fn line_metrics(face: &crate::FontFace) -> (f64, f64) {
|
||||
static CACHE: OnceLock<Mutex<HashMap<u32, (f64, f64)>>> = OnceLock::new();
|
||||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
if let Some(v) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&face.id()) {
|
||||
if let Some(v) = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&face.id()) {
|
||||
return *v;
|
||||
}
|
||||
let mut v = (face.ascent.max(1.0), face.descent.abs());
|
||||
@@ -447,7 +447,7 @@ pub fn line_metrics(face: &crate::FontFace) -> (f64, f64) {
|
||||
v = m;
|
||||
}
|
||||
}
|
||||
cache.lock().unwrap_or_else(|e| e.into_inner()).insert(face.id(), v);
|
||||
cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(face.id(), v);
|
||||
v
|
||||
}
|
||||
|
||||
|
||||
@@ -366,9 +366,8 @@ impl Lexer<'_> {
|
||||
self.pos += after_tag;
|
||||
self.skip_past(">");
|
||||
return;
|
||||
} else {
|
||||
self.pos += k + 2;
|
||||
}
|
||||
self.pos += k + 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,7 +407,7 @@ impl Lexer<'_> {
|
||||
self.skip_past(">");
|
||||
return Some(Tok::End(name));
|
||||
}
|
||||
if !b.get(1).is_some_and(|c| c.is_ascii_alphabetic()) {
|
||||
if !b.get(1).is_some_and(u8::is_ascii_alphabetic) {
|
||||
self.pos += 1;
|
||||
return Some(Tok::Text("<".into()));
|
||||
}
|
||||
@@ -419,7 +418,7 @@ impl Lexer<'_> {
|
||||
let mut attrs = Vec::new();
|
||||
let mut self_close = false;
|
||||
loop {
|
||||
while b.get(i).is_some_and(|c| c.is_ascii_whitespace()) {
|
||||
while b.get(i).is_some_and(u8::is_ascii_whitespace) {
|
||||
i += 1;
|
||||
}
|
||||
match b.get(i) {
|
||||
@@ -448,13 +447,13 @@ impl Lexer<'_> {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
while b.get(i).is_some_and(|c| c.is_ascii_whitespace()) {
|
||||
while b.get(i).is_some_and(u8::is_ascii_whitespace) {
|
||||
i += 1;
|
||||
}
|
||||
let mut val = String::new();
|
||||
if b.get(i) == Some(&b'=') {
|
||||
i += 1;
|
||||
while b.get(i).is_some_and(|c| c.is_ascii_whitespace()) {
|
||||
while b.get(i).is_some_and(u8::is_ascii_whitespace) {
|
||||
i += 1;
|
||||
}
|
||||
match b.get(i) {
|
||||
|
||||
@@ -778,13 +778,13 @@ fn linear_to_latex_at(linear: &str, depth: usize) -> String {
|
||||
o.push('\\');
|
||||
o.push_str(&name);
|
||||
i += 1 + name.len();
|
||||
if chars.get(i).is_some_and(|n| n.is_ascii_alphabetic()) {
|
||||
if chars.get(i).is_some_and(char::is_ascii_alphabetic) {
|
||||
o.push(' ');
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
'%' | '#' | '&' | '$' => {
|
||||
'#'..='&' => {
|
||||
o.push('\\');
|
||||
o.push(c);
|
||||
}
|
||||
@@ -796,7 +796,7 @@ fn linear_to_latex_at(linear: &str, depth: usize) -> String {
|
||||
o.push('\\');
|
||||
o.push_str(name);
|
||||
// A control word needs a space only before a letter.
|
||||
if chars.get(i + 1).is_some_and(|n| n.is_ascii_alphabetic()) {
|
||||
if chars.get(i + 1).is_some_and(char::is_ascii_alphabetic) {
|
||||
o.push(' ');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,7 +268,7 @@ fn list_marker(t: &str) -> Option<Marker> {
|
||||
let (m, len) = if matches!(first, '-' | '+' | '*') {
|
||||
(Marker { ordered: false, ch: first, len: 1, start: 1 }, 1)
|
||||
} else {
|
||||
let digits = t.chars().take_while(|c| c.is_ascii_digit()).count();
|
||||
let digits = t.chars().take_while(char::is_ascii_digit).count();
|
||||
if !(1..=9).contains(&digits) {
|
||||
return None;
|
||||
}
|
||||
@@ -607,7 +607,7 @@ fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut
|
||||
i += 1;
|
||||
}
|
||||
// Blank lines at the end of an item belong between items.
|
||||
while item.len() > 1 && item.last().is_some_and(|x| x.is_empty()) {
|
||||
while item.len() > 1 && item.last().is_some_and(String::is_empty) {
|
||||
item.pop();
|
||||
}
|
||||
let li = ListInfo { ordered: first.ordered, level: level.min(8), start: first.start };
|
||||
@@ -833,7 +833,7 @@ fn unescape(s: &str) -> String {
|
||||
let mut chars = s.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '\\'
|
||||
&& let Some(n) = chars.peek().copied().filter(|n| n.is_ascii_punctuation())
|
||||
&& let Some(n) = chars.peek().copied().filter(char::is_ascii_punctuation)
|
||||
{
|
||||
out.push(n);
|
||||
chars.next();
|
||||
@@ -852,7 +852,7 @@ fn tag_attr(tag: &str, name: &str) -> Option<String> {
|
||||
let at = from + k;
|
||||
let before = lower.get(..at)?.chars().next_back();
|
||||
let after = lower.get(at + name.len()..)?.trim_start();
|
||||
if before.is_some_and(|c| c.is_whitespace()) && after.starts_with('=') {
|
||||
if before.is_some_and(char::is_whitespace) && after.starts_with('=') {
|
||||
let v = after.get(1..)?.trim_start();
|
||||
let q = v.chars().next()?;
|
||||
return Some(if q == '"' || q == '\'' {
|
||||
@@ -1257,7 +1257,7 @@ fn escape_line_start(s: String) -> String {
|
||||
|| (t.starts_with('-') && t.trim_end_matches([' ', '\t']).chars().all(|c| c == '-'))
|
||||
|| thematic_break(t)
|
||||
|| {
|
||||
let d = t.chars().take_while(|c| c.is_ascii_digit()).count();
|
||||
let d = t.chars().take_while(char::is_ascii_digit).count();
|
||||
d > 0
|
||||
&& t.get(d..).is_some_and(|r| {
|
||||
r.starts_with(". ") || r.starts_with(") ") || r.starts_with(".\t") || r.starts_with(")\t") || r == "." || r == ")"
|
||||
|
||||
@@ -88,10 +88,10 @@ fn run(r: &MRun, o: &mut String, eq_arr: bool) {
|
||||
let cs: Vec<char> = r.text.chars().collect();
|
||||
let mut i = 0;
|
||||
while let Some(&c) = cs.get(i) {
|
||||
if c.is_ascii_digit() || (c == '.' && cs.get(i + 1).is_some_and(|d| d.is_ascii_digit())) {
|
||||
if c.is_ascii_digit() || (c == '.' && cs.get(i + 1).is_some_and(char::is_ascii_digit)) {
|
||||
let mut num = String::new();
|
||||
while let Some(&d) =
|
||||
cs.get(i).filter(|d| d.is_ascii_digit() || **d == '.' || **d == ',' && cs.get(i + 1).is_some_and(|n| n.is_ascii_digit()))
|
||||
cs.get(i).filter(|d| d.is_ascii_digit() || **d == '.' || **d == ',' && cs.get(i + 1).is_some_and(char::is_ascii_digit))
|
||||
{
|
||||
num.push(d);
|
||||
i += 1;
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
//! reader (#863).
|
||||
use std::collections::HashSet;
|
||||
|
||||
use wordcraft_doc::{Part, PartKind};
|
||||
use wordcraft_doc::{Document, Part, PartKind};
|
||||
|
||||
use super::*;
|
||||
use super::{Body, Ev, FBlock, Pages, SectionProps, Styles, events, get, model};
|
||||
|
||||
/// Most XML events kept for all headers and footers together; a slot past it is left out.
|
||||
const MAX_EVENTS: usize = 1 << 20;
|
||||
|
||||
@@ -2150,7 +2150,7 @@ impl Reader {
|
||||
self.pending_high = Some(u);
|
||||
} else {
|
||||
let c = match self.pending_high.take() {
|
||||
Some(h) if (0xDC00..0xE000).contains(&u) => char::decode_utf16([h, u]).next().and_then(|r| r.ok()).unwrap_or('\u{FFFD}'),
|
||||
Some(h) if (0xDC00..0xE000).contains(&u) => char::decode_utf16([h, u]).next().and_then(Result::ok).unwrap_or('\u{FFFD}'),
|
||||
_ => char::from_u32(u32::from(u)).unwrap_or('\u{FFFD}'),
|
||||
};
|
||||
self.ch(c);
|
||||
@@ -2638,7 +2638,7 @@ fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
|
||||
let Some(&n) = bytes.get(i + 1) else { break };
|
||||
if n.is_ascii_alphabetic() {
|
||||
let mut j = i + 1;
|
||||
while bytes.get(j).is_some_and(|b| b.is_ascii_alphabetic()) && j - i <= 32 {
|
||||
while bytes.get(j).is_some_and(u8::is_ascii_alphabetic) && j - i <= 32 {
|
||||
j += 1;
|
||||
}
|
||||
let word = std::str::from_utf8(bytes.get(i + 1..j).unwrap_or(b"")).unwrap_or("");
|
||||
@@ -2648,7 +2648,7 @@ fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
|
||||
k += 1;
|
||||
}
|
||||
let d0 = k;
|
||||
while bytes.get(k).is_some_and(|b| b.is_ascii_digit()) && k - d0 < 10 {
|
||||
while bytes.get(k).is_some_and(u8::is_ascii_digit) && k - d0 < 10 {
|
||||
k += 1;
|
||||
}
|
||||
let param = if k > d0 {
|
||||
|
||||
@@ -724,7 +724,8 @@ fn number(x: f64) -> String {
|
||||
return x.to_string();
|
||||
}
|
||||
if x.fract() == 0.0 && x.abs() < 1e15 {
|
||||
return format!("{x:.0}");
|
||||
// `+ 0.0` turns negative zero into positive zero, which would print as "-0".
|
||||
return format!("{:.0}", x + 0.0);
|
||||
}
|
||||
let rounded: f64 = format!("{x:.14e}").parse().unwrap_or(x);
|
||||
if rounded.abs() >= 1e15 || rounded.abs() < 1e-9 { format!("{rounded:E}") } else { rounded.to_string() }
|
||||
@@ -788,7 +789,7 @@ fn iso_datetime(v: &str) -> Option<String> {
|
||||
let y: i64 = it.next()?.parse().ok()?;
|
||||
let m: u32 = it.next()?.parse().ok()?;
|
||||
let d_part = it.next()?;
|
||||
let d_digits: String = d_part.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
let d_digits: String = d_part.chars().take_while(char::is_ascii_digit).collect();
|
||||
let d: u32 = d_digits.parse().ok()?;
|
||||
if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
|
||||
return None;
|
||||
@@ -1006,6 +1007,11 @@ mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn negative_zero_formats_as_zero() {
|
||||
assert_eq!(number(-0.0), "0");
|
||||
}
|
||||
|
||||
fn zip(entries: &[(&str, &str)]) -> Vec<u8> {
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
|
||||
+4
-14
@@ -692,20 +692,10 @@ fn shape_path(kind: ShapeKind, r: &Rect, corner: Option<u32>) -> Option<Path> {
|
||||
pb.move_to(x0, y0);
|
||||
pb.line_to(x1, y1);
|
||||
}
|
||||
ShapeKind::Arrow => {
|
||||
let h = r.h;
|
||||
poly(
|
||||
&mut pb,
|
||||
&[
|
||||
(x0, cy - h * 0.2),
|
||||
(x1 - h * 0.5, cy - h * 0.2),
|
||||
(x1 - h * 0.5, y0),
|
||||
(x1, cy),
|
||||
(x1 - h * 0.5, y1),
|
||||
(x1 - h * 0.5, cy + h * 0.2),
|
||||
(x0, cy + h * 0.2),
|
||||
],
|
||||
)
|
||||
ShapeKind::Arrow | ShapeKind::ArrowLeft | ShapeKind::ArrowUp | ShapeKind::ArrowDown => {
|
||||
let pts: Vec<(f32, f32)> =
|
||||
wordcraft_doc::wrap::arrow_outline(kind, r.w, r.h).iter().map(|&(u, v)| (x0 + u * r.w, y0 + v * r.h)).collect();
|
||||
poly(&mut pb, &pts)
|
||||
}
|
||||
ShapeKind::Star => {
|
||||
let pts: Vec<(f32, f32)> = (0..10)
|
||||
|
||||
@@ -426,6 +426,9 @@ fn shapes_have_paths() {
|
||||
ShapeKind::Star,
|
||||
ShapeKind::Heart,
|
||||
ShapeKind::Arrow,
|
||||
ShapeKind::ArrowLeft,
|
||||
ShapeKind::ArrowUp,
|
||||
ShapeKind::ArrowDown,
|
||||
ShapeKind::Line,
|
||||
ShapeKind::Diamond,
|
||||
ShapeKind::Triangle,
|
||||
@@ -531,7 +534,7 @@ fn unsubsettable_font(family: &str, gid: u16) -> Vec<u8> {
|
||||
i += 1;
|
||||
}
|
||||
};
|
||||
let wide = |s: &str| s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect::<Vec<u8>>();
|
||||
let wide = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
|
||||
assert_eq!(family.len(), "Source Sans 3".len());
|
||||
rename(&mut f, &wide("Source Sans 3"), &wide(family));
|
||||
rename(&mut f, b"Source Sans 3", family.as_bytes());
|
||||
@@ -1208,10 +1211,10 @@ fn cosmetic_metafile_pens_are_thin_but_visible() {
|
||||
fn emf_text_records(text: &str) -> Vec<Vec<u8>> {
|
||||
let mut font = [82i32.to_le_bytes(), 104u32.to_le_bytes(), 1i32.to_le_bytes(), (-20i32).to_le_bytes()].concat();
|
||||
font.extend([0i32, 0, 0, 400, 0, 0].iter().flat_map(|v| v.to_le_bytes()));
|
||||
let mut name: Vec<u8> = "Arial".encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
|
||||
let mut name: Vec<u8> = "Arial".encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
name.resize(64, 0);
|
||||
font.extend(name);
|
||||
let units: Vec<u8> = text.encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
|
||||
let units: Vec<u8> = text.encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
let padded = units.len().div_ceil(4) * 4;
|
||||
let mut out = [84u32.to_le_bytes(), ((76 + padded) as u32).to_le_bytes()].concat();
|
||||
out.extend([0i32, 0, 100, 100, 1, 0, 0, 5, 40, text.encode_utf16().count() as i32, 76, 0, 0, 0, 0, 0, 0].iter().flat_map(|v| v.to_le_bytes()));
|
||||
|
||||
@@ -31,11 +31,11 @@ const NAMES: &[&str] = &["cs_CZ", "cs-CZ", "cs"];
|
||||
/// [`install_dictionary`], or an empty one when none is available (Czech spelling is off
|
||||
/// then; see [`has_dictionary`]).
|
||||
pub fn dictionary() -> Arc<AffixDict> {
|
||||
if let Some(d) = DICT.read().unwrap_or_else(|e| e.into_inner()).dict.as_ref() {
|
||||
if let Some(d) = DICT.read().unwrap_or_else(std::sync::PoisonError::into_inner).dict.as_ref() {
|
||||
return d.clone();
|
||||
}
|
||||
let loaded = Arc::new(load_installed());
|
||||
let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut slot = DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
// Another thread (or an install) may have filled it meanwhile: that one wins.
|
||||
slot.dict.get_or_insert(loaded).clone()
|
||||
}
|
||||
@@ -56,7 +56,7 @@ fn load_installed() -> AffixDict {
|
||||
/// UTF-8), for example one the user picked. On error the current dictionary stays.
|
||||
pub fn install_dictionary(aff: &str, dic: &str) -> Result<(), String> {
|
||||
let d = AffixDict::from_hunspell(aff, dic, &[])?;
|
||||
let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut slot = DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
slot.dict = Some(Arc::new(d));
|
||||
slot.in_memory = true;
|
||||
Ok(())
|
||||
@@ -65,7 +65,7 @@ pub fn install_dictionary(aff: &str, dic: &str) -> Result<(), String> {
|
||||
/// Forget the loaded dictionary, so the next check looks for an installed one again (after
|
||||
/// [`crate::hunspell::set_dictionary_dir`] or a dictionary was installed).
|
||||
pub fn reload_dictionary() {
|
||||
let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut slot = DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
slot.dict = None;
|
||||
slot.in_memory = false;
|
||||
}
|
||||
@@ -75,7 +75,7 @@ pub fn reload_dictionary() {
|
||||
/// none. A dictionary given with [`install_dictionary`] has no file and isn't reported, even if a
|
||||
/// matching file also happens to exist.
|
||||
pub fn installed_path() -> Option<std::path::PathBuf> {
|
||||
if DICT.read().unwrap_or_else(|e| e.into_inner()).in_memory {
|
||||
if DICT.read().unwrap_or_else(std::sync::PoisonError::into_inner).in_memory {
|
||||
return None;
|
||||
}
|
||||
crate::hunspell::find(NAMES).map(|(_, dic)| dic)
|
||||
|
||||
@@ -25,11 +25,11 @@ const NAMES: &[&str] = &["hr_HR", "hr-HR", "hr"];
|
||||
/// [`install_dictionary`], or an empty one when none is available (Croatian spelling is off
|
||||
/// then; see [`has_dictionary`]).
|
||||
pub fn dictionary() -> Arc<AffixDict> {
|
||||
if let Some(d) = DICT.read().unwrap_or_else(|e| e.into_inner()).as_ref() {
|
||||
if let Some(d) = DICT.read().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
|
||||
return d.clone();
|
||||
}
|
||||
let loaded = Arc::new(load_installed());
|
||||
let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut slot = DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
// Another thread (or an install) may have filled it meanwhile: that one wins.
|
||||
slot.get_or_insert(loaded).clone()
|
||||
}
|
||||
@@ -51,14 +51,14 @@ fn load_installed() -> AffixDict {
|
||||
/// UTF-8), for example one the user picked. On error the current dictionary stays.
|
||||
pub fn install_dictionary(aff: &str, dic: &str) -> Result<(), String> {
|
||||
let d = AffixDict::from_hunspell(aff, dic, &[])?;
|
||||
*DICT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(d));
|
||||
*DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::new(d));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget the loaded dictionary, so the next check looks for an installed one again (after
|
||||
/// [`crate::hunspell::set_dictionary_dir`] or a dictionary was installed).
|
||||
pub fn reload_dictionary() {
|
||||
*DICT.write().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
*DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner) = None;
|
||||
}
|
||||
|
||||
/// The `.dic` file of the Hunspell `hr_HR` dictionary found in the dictionaries folder or the
|
||||
|
||||
@@ -27,7 +27,7 @@ static LOAD_ERRORS: RwLock<Vec<(String, PathBuf, String)>> = RwLock::new(Vec::ne
|
||||
/// Remember (or, for `Ok`, forget) why the dictionary `key` found at `dic` failed to load, and
|
||||
/// hand the result back unchanged.
|
||||
pub fn note_load<T>(key: &str, dic: &Path, result: Result<T, String>) -> Result<T, String> {
|
||||
let mut errors = LOAD_ERRORS.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut errors = LOAD_ERRORS.write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
errors.retain(|(k, _, _)| k != key);
|
||||
if let Err(e) = &result {
|
||||
errors.push((key.to_string(), dic.to_path_buf(), e.clone()));
|
||||
@@ -37,19 +37,19 @@ pub fn note_load<T>(key: &str, dic: &Path, result: Result<T, String>) -> Result<
|
||||
|
||||
/// Forget the load error of the dictionary `key` (none found, or it is about to be searched for again).
|
||||
pub fn clear_load_error(key: &str) {
|
||||
LOAD_ERRORS.write().unwrap_or_else(|e| e.into_inner()).retain(|(k, _, _)| k != key);
|
||||
LOAD_ERRORS.write().unwrap_or_else(std::sync::PoisonError::into_inner).retain(|(k, _, _)| k != key);
|
||||
}
|
||||
|
||||
/// The `.dic` file and the reason of the last failed load of the dictionary `key`, if any.
|
||||
pub fn load_error(key: &str) -> Option<(PathBuf, String)> {
|
||||
LOAD_ERRORS.read().unwrap_or_else(|e| e.into_inner()).iter().find(|(k, _, _)| k == key).map(|(_, p, e)| (p.clone(), e.clone()))
|
||||
LOAD_ERRORS.read().unwrap_or_else(std::sync::PoisonError::into_inner).iter().find(|(k, _, _)| k == key).map(|(_, p, e)| (p.clone(), e.clone()))
|
||||
}
|
||||
|
||||
/// Search `dir` before the system locations (a "dictionaries folder" setting); `None` clears it.
|
||||
/// Takes effect for dictionaries not loaded yet.
|
||||
pub fn set_dictionary_dir(dir: Option<PathBuf>) {
|
||||
*EXTRA_DIR.write().unwrap_or_else(|e| e.into_inner()) = dir;
|
||||
LOAD_ERRORS.write().unwrap_or_else(|e| e.into_inner()).clear(); // they belong to the old search path
|
||||
*EXTRA_DIR.write().unwrap_or_else(std::sync::PoisonError::into_inner) = dir;
|
||||
LOAD_ERRORS.write().unwrap_or_else(std::sync::PoisonError::into_inner).clear(); // they belong to the old search path
|
||||
}
|
||||
|
||||
/// The LibreOffice extension directories a Hunspell dictionary name may live in: LibreOffice's
|
||||
@@ -67,7 +67,7 @@ fn lo_ext_dirs(name: &str) -> Vec<String> {
|
||||
/// language is being searched for, adds LibreOffice's extension folders for it (`dict-pt-BR`).
|
||||
pub fn search_dirs(lo_exts: Option<&[String]>) -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = Vec::new();
|
||||
if let Some(d) = EXTRA_DIR.read().unwrap_or_else(|e| e.into_inner()).clone() {
|
||||
if let Some(d) = EXTRA_DIR.read().unwrap_or_else(std::sync::PoisonError::into_inner).clone() {
|
||||
dirs.push(d);
|
||||
}
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
|
||||
@@ -30,11 +30,11 @@ const CLITICS: &[&str] = &["se", "nya", "ku", "mu", "lah", "kah", "pun"];
|
||||
/// [`install_dictionary`], or an empty one when none is available (Indonesian spelling is off
|
||||
/// then; see [`has_dictionary`]).
|
||||
pub fn dictionary() -> Arc<AffixDict> {
|
||||
if let Some(d) = DICT.read().unwrap_or_else(|e| e.into_inner()).as_ref() {
|
||||
if let Some(d) = DICT.read().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
|
||||
return d.clone();
|
||||
}
|
||||
let loaded = Arc::new(load_installed());
|
||||
let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut slot = DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
// Another thread (or an install) may have filled it meanwhile: that one wins.
|
||||
slot.get_or_insert(loaded).clone()
|
||||
}
|
||||
@@ -55,14 +55,14 @@ fn load_installed() -> AffixDict {
|
||||
/// UTF-8), for example one the user picked. On error the current dictionary stays.
|
||||
pub fn install_dictionary(aff: &str, dic: &str) -> Result<(), String> {
|
||||
let d = AffixDict::from_hunspell(aff, dic, &[])?;
|
||||
*DICT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(d));
|
||||
*DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::new(d));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget the loaded dictionary, so the next check looks for an installed one again (after
|
||||
/// [`crate::hunspell::set_dictionary_dir`] or a dictionary was installed).
|
||||
pub fn reload_dictionary() {
|
||||
*DICT.write().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
*DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner) = None;
|
||||
}
|
||||
|
||||
/// The `.dic` file of the Hunspell `id_ID` dictionary found in the dictionaries folder or the
|
||||
|
||||
@@ -276,7 +276,7 @@ fn e_apostrophe(text: &str, ws: &[(usize, usize)], v: &mut Vec<Issue>) {
|
||||
}
|
||||
let mut after = text.get(*b..).unwrap_or("").chars();
|
||||
let Some(ap) = after.next().filter(|&c| is_apostrophe(c)) else { continue };
|
||||
let ends = after.next().is_none_or(|c| c.is_whitespace());
|
||||
let ends = after.next().is_none_or(char::is_whitespace);
|
||||
let quoted = text.get(..*a).and_then(|t| t.chars().next_back()).is_some_and(|c| is_apostrophe(c) || c == '‘');
|
||||
if ends && !quoted {
|
||||
v.push(issue(*a, *b + ap.len_utf8(), "«È» si scrive con l'accento, non con l'apostrofo".into(), vec!["È".into()]));
|
||||
|
||||
@@ -34,11 +34,11 @@ const NAMES: &[&str] = &["it_IT", "it_CH", "it-IT", "it"];
|
||||
/// [`install_dictionary`], or an empty one when none is available (Italian spelling is off
|
||||
/// then; see [`has_dictionary`]).
|
||||
pub fn dictionary() -> Arc<AffixDict> {
|
||||
if let Some(d) = DICT.read().unwrap_or_else(|e| e.into_inner()).as_ref() {
|
||||
if let Some(d) = DICT.read().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
|
||||
return d.clone();
|
||||
}
|
||||
let loaded = Arc::new(load_installed());
|
||||
let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut slot = DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
// Another thread (or an install) may have filled it meanwhile: that one wins.
|
||||
slot.get_or_insert(loaded).clone()
|
||||
}
|
||||
@@ -59,14 +59,14 @@ fn load_installed() -> AffixDict {
|
||||
/// UTF-8), for example one the user picked. On error the current dictionary stays.
|
||||
pub fn install_dictionary(aff: &str, dic: &str) -> Result<(), String> {
|
||||
let d = AffixDict::from_hunspell(aff, dic, &[])?;
|
||||
*DICT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(d));
|
||||
*DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::new(d));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget the loaded dictionary, so the next check looks for an installed one again (after
|
||||
/// [`crate::hunspell::set_dictionary_dir`] or a dictionary was installed).
|
||||
pub fn reload_dictionary() {
|
||||
*DICT.write().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
*DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner) = None;
|
||||
}
|
||||
|
||||
/// The `.dic` file of the Hunspell `it_IT` dictionary found in the dictionaries folder or the
|
||||
|
||||
+10
-10
@@ -130,14 +130,14 @@ fn dictionary_key(w: &str) -> String {
|
||||
|
||||
/// Add a word to the user dictionary ("Add to Dictionary"). It applies to every language.
|
||||
pub fn add_word(w: &str) {
|
||||
if user_words().write().unwrap_or_else(|e| e.into_inner()).insert(dictionary_key(w)) {
|
||||
if user_words().write().unwrap_or_else(std::sync::PoisonError::into_inner).insert(dictionary_key(w)) {
|
||||
DICTIONARY_GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Take a word out of the user dictionary (File › Options › Proofing). Returns whether it was there.
|
||||
pub fn remove_word(w: &str) -> bool {
|
||||
let removed = user_words().write().unwrap_or_else(|e| e.into_inner()).remove(&dictionary_key(w));
|
||||
let removed = user_words().write().unwrap_or_else(std::sync::PoisonError::into_inner).remove(&dictionary_key(w));
|
||||
if removed {
|
||||
DICTIONARY_GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
@@ -152,7 +152,7 @@ fn ignored_words() -> &'static RwLock<HashSet<String>> {
|
||||
/// Ignore a word everywhere until the app closes ("Ignore All"). Unlike [`add_word`] it isn't
|
||||
/// part of the user dictionary: it is never listed, saved or loaded, as in Word.
|
||||
pub fn ignore_word(w: &str) {
|
||||
if ignored_words().write().unwrap_or_else(|e| e.into_inner()).insert(dictionary_key(w)) {
|
||||
if ignored_words().write().unwrap_or_else(std::sync::PoisonError::into_inner).insert(dictionary_key(w)) {
|
||||
DICTIONARY_GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
@@ -220,13 +220,13 @@ pub fn dictionary_generation() -> u64 {
|
||||
/// the entries are kept ([`dictionary_key`]).
|
||||
pub(crate) fn in_user_dictionary(lower: &str) -> bool {
|
||||
let key = composed(lower);
|
||||
user_words().read().unwrap_or_else(|e| e.into_inner()).contains(&*key)
|
||||
|| ignored_words().read().unwrap_or_else(|e| e.into_inner()).contains(&*key)
|
||||
user_words().read().unwrap_or_else(std::sync::PoisonError::into_inner).contains(&*key)
|
||||
|| ignored_words().read().unwrap_or_else(std::sync::PoisonError::into_inner).contains(&*key)
|
||||
}
|
||||
|
||||
/// Words in the user dictionary.
|
||||
pub fn user_dictionary() -> Vec<String> {
|
||||
let mut v: Vec<String> = user_words().read().unwrap_or_else(|e| e.into_inner()).iter().cloned().collect();
|
||||
let mut v: Vec<String> = user_words().read().unwrap_or_else(std::sync::PoisonError::into_inner).iter().cloned().collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
@@ -274,7 +274,7 @@ fn read_dictionary_file(path: &std::path::Path) -> Vec<String> {
|
||||
/// never a panic and never an error that stops startup. Returns the words added.
|
||||
pub fn load_user_dictionary(path: &std::path::Path) -> Vec<String> {
|
||||
let words = read_dictionary_file(path);
|
||||
let mut set = user_words().write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut set = user_words().write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut added = Vec::new();
|
||||
for w in words {
|
||||
if set.len() >= MAX_CUSTOM_WORDS {
|
||||
@@ -300,7 +300,7 @@ pub fn save_user_dictionary(path: &std::path::Path, removed: &[String]) -> std::
|
||||
let removed: HashSet<String> = removed.iter().map(|w| dictionary_key(w)).collect();
|
||||
let on_disk = read_dictionary_file(path);
|
||||
{
|
||||
let mut set = user_words().write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut set = user_words().write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut changed = false;
|
||||
for w in on_disk {
|
||||
if set.len() >= MAX_CUSTOM_WORDS {
|
||||
@@ -342,7 +342,7 @@ const COMMON: &str = "a an the and or but if of to in on at by for with from as
|
||||
const EXTRA_EN: &str = include_str!("../../../assets/spelling/en-extra.txt");
|
||||
|
||||
fn extra_words() -> &'static HashSet<&'static str> {
|
||||
static E: OnceLock<HashSet<&'static str>> = OnceLock::new();
|
||||
static E: OnceLock<HashSet<&str>> = OnceLock::new();
|
||||
E.get_or_init(|| EXTRA_EN.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).collect())
|
||||
}
|
||||
|
||||
@@ -943,7 +943,7 @@ fn grammar_en(text: &str, capital_first: bool) -> Vec<Issue> {
|
||||
if gap == " "
|
||||
&& (w0 == "an" || w0 == "An")
|
||||
&& w1.chars().next().is_some_and(|c| c.is_alphabetic() && !"aeiouAEIOUhH".contains(c))
|
||||
&& !w1.chars().all(|c| c.is_uppercase())
|
||||
&& !w1.chars().all(char::is_uppercase)
|
||||
{
|
||||
v.push(Issue {
|
||||
start: *a0,
|
||||
|
||||
@@ -33,11 +33,11 @@ const NAMES: &[&str] = &["pl_PL", "pl-PL", "pl"];
|
||||
/// [`install_dictionary`], or an empty one when none is available (Polish spelling is off then;
|
||||
/// see [`has_dictionary`]).
|
||||
pub fn dictionary() -> Arc<AffixDict> {
|
||||
if let Some(d) = DICT.read().unwrap_or_else(|e| e.into_inner()).as_ref() {
|
||||
if let Some(d) = DICT.read().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
|
||||
return d.clone();
|
||||
}
|
||||
let loaded = Arc::new(load_installed());
|
||||
let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut slot = DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
// Another thread (or an install) may have filled it meanwhile: that one wins.
|
||||
slot.get_or_insert(loaded).clone()
|
||||
}
|
||||
@@ -58,14 +58,14 @@ fn load_installed() -> AffixDict {
|
||||
/// for example one the user picked. On error the current dictionary stays.
|
||||
pub fn install_dictionary(aff: &str, dic: &str) -> Result<(), String> {
|
||||
let d = AffixDict::from_hunspell(aff, dic, &[])?;
|
||||
*DICT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(d));
|
||||
*DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::new(d));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget the loaded dictionary, so the next check looks for an installed one again (after
|
||||
/// [`crate::hunspell::set_dictionary_dir`] or a dictionary was installed).
|
||||
pub fn reload_dictionary() {
|
||||
*DICT.write().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
*DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner) = None;
|
||||
}
|
||||
|
||||
/// The `.dic` file of the installed Polish dictionary, searching the dictionaries folder and
|
||||
|
||||
@@ -32,11 +32,11 @@ const NAMES: &[&str] = &["pt_BR", "pt-BR", "pt"];
|
||||
/// [`install_dictionary`], or an empty one when none is available (Portuguese spelling is off
|
||||
/// then; see [`has_dictionary`]).
|
||||
pub fn dictionary() -> Arc<AffixDict> {
|
||||
if let Some(d) = DICT.read().unwrap_or_else(|e| e.into_inner()).as_ref() {
|
||||
if let Some(d) = DICT.read().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
|
||||
return d.clone();
|
||||
}
|
||||
let loaded = Arc::new(load_installed());
|
||||
let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut slot = DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
// Another thread (or an install) may have filled it meanwhile: that one wins.
|
||||
slot.get_or_insert(loaded).clone()
|
||||
}
|
||||
@@ -57,14 +57,14 @@ fn load_installed() -> AffixDict {
|
||||
/// UTF-8), for example one the user picked. On error the current dictionary stays.
|
||||
pub fn install_dictionary(aff: &str, dic: &str) -> Result<(), String> {
|
||||
let d = AffixDict::from_hunspell(aff, dic, &[])?;
|
||||
*DICT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(d));
|
||||
*DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::new(d));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget the loaded dictionary, so the next check looks for an installed one again (after
|
||||
/// [`crate::hunspell::set_dictionary_dir`] or a dictionary was installed).
|
||||
pub fn reload_dictionary() {
|
||||
*DICT.write().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
*DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner) = None;
|
||||
}
|
||||
|
||||
/// The `.dic` file of the installed Portuguese dictionary, searching the dictionaries folder and
|
||||
|
||||
@@ -52,11 +52,11 @@ const NAMES: &[&str] = &["ru_RU", "ru-RU", "ru"];
|
||||
/// [`install_dictionary`], or an empty one when none is available (Russian spelling is off then;
|
||||
/// see [`has_dictionary`]).
|
||||
pub fn dictionary() -> Arc<AffixDict> {
|
||||
if let Some(d) = DICT.read().unwrap_or_else(|e| e.into_inner()).dict.as_ref() {
|
||||
if let Some(d) = DICT.read().unwrap_or_else(std::sync::PoisonError::into_inner).dict.as_ref() {
|
||||
return d.clone();
|
||||
}
|
||||
let loaded = Arc::new(load_installed());
|
||||
let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut slot = DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
// Another thread (or an install) may have filled it meanwhile: that one wins.
|
||||
slot.dict.get_or_insert(loaded).clone()
|
||||
}
|
||||
@@ -77,7 +77,7 @@ fn load_installed() -> AffixDict {
|
||||
/// for example one the user picked. On error the current dictionary stays.
|
||||
pub fn install_dictionary(aff: &str, dic: &str) -> Result<(), String> {
|
||||
let d = AffixDict::from_hunspell(aff, dic, &[])?;
|
||||
let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut slot = DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
slot.dict = Some(Arc::new(d));
|
||||
slot.in_memory = true;
|
||||
Ok(())
|
||||
@@ -86,7 +86,7 @@ pub fn install_dictionary(aff: &str, dic: &str) -> Result<(), String> {
|
||||
/// Forget the loaded dictionary, so the next check looks for an installed one again (after
|
||||
/// [`crate::hunspell::set_dictionary_dir`] or a dictionary was installed).
|
||||
pub fn reload_dictionary() {
|
||||
let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut slot = DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
slot.dict = None;
|
||||
slot.in_memory = false;
|
||||
}
|
||||
@@ -96,7 +96,7 @@ pub fn reload_dictionary() {
|
||||
/// none. A dictionary given with [`install_dictionary`] has no file and isn't reported, even if a
|
||||
/// matching file also happens to exist.
|
||||
pub fn installed_path() -> Option<std::path::PathBuf> {
|
||||
if DICT.read().unwrap_or_else(|e| e.into_inner()).in_memory {
|
||||
if DICT.read().unwrap_or_else(std::sync::PoisonError::into_inner).in_memory {
|
||||
return None;
|
||||
}
|
||||
crate::hunspell::find(NAMES).map(|(_, dic)| dic)
|
||||
@@ -109,7 +109,7 @@ pub fn has_dictionary() -> bool {
|
||||
|
||||
/// Is `c` a letter of the Cyrillic script (the only one the Russian dictionary covers)?
|
||||
pub(crate) fn is_cyrillic(c: char) -> bool {
|
||||
matches!(c, '\u{0400}'..='\u{04FF}' | '\u{0500}'..='\u{052F}')
|
||||
matches!(c, '\u{0400}'..='\u{052F}')
|
||||
}
|
||||
|
||||
/// Particles joined to a word with a hyphen (`кто-то`, `где-нибудь`, `давай-ка`, `всё-таки`,
|
||||
|
||||
@@ -30,11 +30,11 @@ const NAMES: &[&str] = &["tr_TR", "tr-TR", "tr"];
|
||||
/// The Turkish dictionary: the installed Hunspell `tr_TR` (read on first use), one given with
|
||||
/// [`install_dictionary`], or an empty one when none is available.
|
||||
pub fn dictionary() -> Arc<TrDict> {
|
||||
if let Some(d) = DICT.read().unwrap_or_else(|e| e.into_inner()).as_ref() {
|
||||
if let Some(d) = DICT.read().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
|
||||
return d.clone();
|
||||
}
|
||||
let loaded = Arc::new(load_installed());
|
||||
let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut slot = DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
slot.get_or_insert(loaded).clone()
|
||||
}
|
||||
|
||||
@@ -61,13 +61,13 @@ fn load_installed() -> TrDict {
|
||||
/// Use this Turkish dictionary from now on.
|
||||
pub fn install_dictionary(aff: &str, dic: &str) -> Result<(), String> {
|
||||
let d = TrDict::from_hunspell(aff, dic)?;
|
||||
*DICT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(d));
|
||||
*DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::new(d));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reload the dictionary on next check.
|
||||
pub fn reload_dictionary() {
|
||||
*DICT.write().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
*DICT.write().unwrap_or_else(std::sync::PoisonError::into_inner) = None;
|
||||
}
|
||||
|
||||
/// The `.dic` file of the Hunspell `tr_TR` dictionary found in the dictionaries folder or the
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
//! Decoded-media cache: hits need the same allocation, and the byte budget bounds what is kept.
|
||||
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::Document;
|
||||
use wordcraft_doc::props::Rgb;
|
||||
use wordcraft_layout::display::{Draw, Stroke};
|
||||
|
||||
use super::{CACHE_BYTES, MediaCache, RenderOptions, render_items, wave_path};
|
||||
|
||||
#[test]
|
||||
fn media_cache_only_hits_the_same_allocation() {
|
||||
|
||||
+20
-15
@@ -181,7 +181,7 @@ impl<T: Clone> MediaCache<T> {
|
||||
}
|
||||
|
||||
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(|e| e.into_inner())
|
||||
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn images() -> &'static Mutex<MediaCache<Option<Arc<Pixmap>>>> {
|
||||
@@ -1188,17 +1188,13 @@ pub fn shape_path(kind: ShapeKind, r: kurbo::Rect, corner: Option<u32>) -> BezPa
|
||||
p.line_to((r.x1, r.y1));
|
||||
p
|
||||
}
|
||||
ShapeKind::Arrow => {
|
||||
let h = r.height();
|
||||
poly(&[
|
||||
(r.x0, cy - h * 0.2),
|
||||
(r.x1 - h * 0.5, cy - h * 0.2),
|
||||
(r.x1 - h * 0.5, r.y0),
|
||||
(r.x1, cy),
|
||||
(r.x1 - h * 0.5, r.y1),
|
||||
(r.x1 - h * 0.5, cy + h * 0.2),
|
||||
(r.x0, cy + h * 0.2),
|
||||
])
|
||||
ShapeKind::Arrow | ShapeKind::ArrowLeft | ShapeKind::ArrowUp | ShapeKind::ArrowDown => {
|
||||
let (w, h) = (r.width(), r.height());
|
||||
let pts: Vec<(f64, f64)> = wordcraft_doc::wrap::arrow_outline(kind, w as f32, h as f32)
|
||||
.iter()
|
||||
.map(|&(u, v)| (r.x0 + f64::from(u) * w, r.y0 + f64::from(v) * h))
|
||||
.collect();
|
||||
poly(&pts)
|
||||
}
|
||||
ShapeKind::Star => {
|
||||
let mut pts = Vec::new();
|
||||
@@ -2192,9 +2188,18 @@ mod tests {
|
||||
#[test]
|
||||
fn shapes_have_paths() {
|
||||
let r = kurbo::Rect::new(0.0, 0.0, 10.0, 10.0);
|
||||
for k in
|
||||
[ShapeKind::Rectangle, ShapeKind::Ellipse, ShapeKind::FlowchartOr, ShapeKind::Star, ShapeKind::Heart, ShapeKind::Arrow, ShapeKind::Line]
|
||||
{
|
||||
for k in [
|
||||
ShapeKind::Rectangle,
|
||||
ShapeKind::Ellipse,
|
||||
ShapeKind::FlowchartOr,
|
||||
ShapeKind::Star,
|
||||
ShapeKind::Heart,
|
||||
ShapeKind::Arrow,
|
||||
ShapeKind::ArrowLeft,
|
||||
ShapeKind::ArrowUp,
|
||||
ShapeKind::ArrowDown,
|
||||
ShapeKind::Line,
|
||||
] {
|
||||
assert!(!shape_path(k, r, None).elements().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
//! Colour glyphs (#1269): emoji from colour fonts (bitmap strikes, COLR) are drawn in colour.
|
||||
|
||||
use super::*;
|
||||
use wordcraft_layout::display::page_display;
|
||||
use wordcraft_doc::Document;
|
||||
use wordcraft_fonts::FontDb;
|
||||
use wordcraft_layout::display::{DisplayOptions, Draw, page_display};
|
||||
use wordcraft_layout::{LayoutCache, LayoutOptions, layout};
|
||||
|
||||
use super::{RenderOptions, Rendered, render_page};
|
||||
|
||||
/// Colour emoji families the system fallback may pick.
|
||||
const COLOR_EMOJI: &[&str] = &["Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji"];
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ fn locales(dir: &std::path::Path) -> Result<Vec<(String, Option<std::path::PathB
|
||||
let mut out = Vec::new();
|
||||
for entry in list.flatten() {
|
||||
let path = entry.path();
|
||||
let Some(code) = path.file_name().and_then(|n| n.to_str()).map(str::to_string) else { continue };
|
||||
let Some(code) = path.file_name().and_then(std::ffi::OsStr::to_str).map(str::to_string) else { continue };
|
||||
let app = path.join(APP_CATALOG);
|
||||
if !app.is_file() {
|
||||
continue;
|
||||
|
||||
@@ -199,8 +199,19 @@ pub struct CanvasState {
|
||||
pub(crate) tab_drag: Option<crate::ruler_tabs::TabDrag>,
|
||||
/// Whether the I-beam over the page is drawn rather than the system's (`ibeam`).
|
||||
pub(crate) drawn_ibeam: bool,
|
||||
/// The rows and columns hovered in Insert › Table's grid this frame: the page shows that
|
||||
/// table where it would go (#3931). Set by the grid each frame, taken by [`show`].
|
||||
pub(crate) table_preview: Option<(usize, usize)>,
|
||||
/// The last previewed table's layout, the copy of the document it was laid out from and the
|
||||
/// copy's selection, with
|
||||
/// what it was laid out for (rows, columns, document id, revision, selection, and the
|
||||
/// document's own layout, which a view change replaces), so a still pointer doesn't lay the
|
||||
/// document out every frame. Set only while the page shows the preview.
|
||||
table_preview_layout: Option<(TablePreviewKey, wordcraft_doc::Document, wordcraft_engine::Selection, std::sync::Arc<DocLayout>)>,
|
||||
}
|
||||
|
||||
type TablePreviewKey = ((usize, usize, u64, u64, wordcraft_engine::Selection), std::sync::Arc<DocLayout>);
|
||||
|
||||
impl CanvasState {
|
||||
/// Stop a text-selection drag when the pointer gesture becomes an object drag.
|
||||
pub(crate) fn cancel_text_drag(&mut self) {
|
||||
@@ -310,6 +321,8 @@ impl Default for CanvasState {
|
||||
tab_kind: Default::default(),
|
||||
tab_drag: None,
|
||||
drawn_ibeam: crate::ibeam::DRAWN,
|
||||
table_preview: None,
|
||||
table_preview_layout: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -935,6 +948,47 @@ fn drawing_grid_step(spacing: f32, scale: f32) -> f32 {
|
||||
|
||||
/// Draw the document canvas in the space `ui` has left, one frame: the pages, caret, selection
|
||||
/// and rulers, with the mouse editing and the pointer shape over them.
|
||||
/// The layout the page shows: the document's own, or with the table Insert › Table's grid is
|
||||
/// hovering over (#3931). The preview is laid out on a copy, so the document and its undo history
|
||||
/// change only when a square is clicked.
|
||||
fn shown_layout(app: &mut WordApp) -> std::sync::Arc<DocLayout> {
|
||||
let Some((rows, cols)) = app.canvas.table_preview.take() else {
|
||||
app.canvas.table_preview_layout = None;
|
||||
return app.session.layout();
|
||||
};
|
||||
// The caret can move, or the view change, with the grid still hovered: the table then goes
|
||||
// somewhere else, or the page is laid out to another width.
|
||||
let key = ((rows, cols, app.session.document_id(), app.session.rev(), app.session.sel.clone()), app.session.layout());
|
||||
if let Some((k, _, _, l)) = &app.canvas.table_preview_layout
|
||||
&& k.0 == key.0
|
||||
&& std::sync::Arc::ptr_eq(&k.1, &key.1)
|
||||
{
|
||||
return l.clone();
|
||||
}
|
||||
match app.session.preview_layout("insert.table", &json!({"rows": rows, "cols": cols})) {
|
||||
Some((doc, sel, l)) => {
|
||||
app.canvas.table_preview_layout = Some((key, doc, sel, l.clone()));
|
||||
l
|
||||
}
|
||||
None => {
|
||||
app.canvas.table_preview_layout = None;
|
||||
app.session.layout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The document the pages of [`shown_layout`] are drawn from: the previewed copy while the page
|
||||
/// shows Insert › Table's preview, whose paragraph paths are the layout's (#3931).
|
||||
fn page_doc(app: &WordApp) -> &wordcraft_doc::Document {
|
||||
app.canvas.table_preview_layout.as_ref().map_or(&app.session.doc, |(_, doc, _, _)| doc)
|
||||
}
|
||||
|
||||
/// The previewed copy's selection while the page shows Insert › Table's preview: the caret and
|
||||
/// selection are drawn from it, as the document's own name other places in the preview's layout.
|
||||
fn preview_sel(app: &WordApp) -> Option<&wordcraft_engine::Selection> {
|
||||
app.canvas.table_preview_layout.as_ref().map(|(_, _, sel, _)| sel)
|
||||
}
|
||||
|
||||
pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let full = ui.available_rect_before_wrap();
|
||||
@@ -996,7 +1050,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
app.canvas.alt_code.clear();
|
||||
}
|
||||
crate::ink::replay_tick(app, ui.ctx());
|
||||
let layout = app.session.layout();
|
||||
let layout = shown_layout(app);
|
||||
if let Some(anchor) = reflow_anchor
|
||||
&& let Some(page) = reading_anchor_page(&layout, &anchor)
|
||||
{
|
||||
@@ -1081,7 +1135,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
// Where the selection really ends, also when it took whole table cells.
|
||||
let caret = layout.caret_on(&app.session.sel_ends().focus, app.session.page_hint);
|
||||
let caret = layout.caret_on(&preview_sel(app).unwrap_or(app.session.sel_ends()).focus, app.session.page_hint);
|
||||
// Editing a header/footer (or a note) dims the body; once per frame, for every page.
|
||||
let dim_body = dims_body(app, &layout);
|
||||
if let Some(c) = caret
|
||||
@@ -1269,7 +1323,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
let mut opts = page_render_options(app, dim_body);
|
||||
// Only on the page it is on: the `Pos` can't match an object of another page.
|
||||
opts.display.hide_object = hidden.as_ref().filter(|(page, _)| *page == i).map(|(_, p)| p.clone());
|
||||
let img = wordcraft_render::render_page(&app.session.doc, page, scale_px, &opts);
|
||||
let img = wordcraft_render::render_page(page_doc(app), page, scale_px, &opts);
|
||||
let ci = page_image(&img);
|
||||
match app.canvas.textures.get_mut(&i) {
|
||||
Some((k, tex)) => {
|
||||
@@ -1328,7 +1382,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
// The tiny subtraction avoids a 1px overshoot from round-tripping f32
|
||||
// pixels through page points and render_area's ceil() conversion.
|
||||
let img = wordcraft_render::render_area(
|
||||
&app.session.doc,
|
||||
page_doc(app),
|
||||
page,
|
||||
0.0,
|
||||
start_px as f32 / scale_px,
|
||||
@@ -1451,8 +1505,9 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
} else {
|
||||
balloons(app, ui, &painter, &rects, &layout, geo.scale, view_rect);
|
||||
}
|
||||
// Find › Reading Highlight.
|
||||
let marks: Vec<(Pos, Pos)> = app.session.find_highlights().iter().take(5000).cloned().collect();
|
||||
// Find › Reading Highlight. Its places are the document's, not the preview's.
|
||||
let marks: Vec<(Pos, Pos)> =
|
||||
if preview_sel(app).is_some() { Vec::new() } else { app.session.find_highlights().iter().take(5000).cloned().collect() };
|
||||
for (a, b) in &marks {
|
||||
for (pi, r) in layout.selection_rects(&app.session.doc, a, b, app.session.page_hint) {
|
||||
if let Some(pr) = rects.get(pi) {
|
||||
@@ -1465,7 +1520,14 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
// Selection (a selected object shows its frame instead): whole table cells shade the
|
||||
// cells (an empty one too), a column selection each row's piece, text running into or
|
||||
// across a table the whole cells of the rows it takes.
|
||||
let selected: Vec<_> = if crate::objects::selected(app).is_some() {
|
||||
let selected: Vec<_> = if let Some(sel) = preview_sel(app) {
|
||||
let (a, b) = sel.ordered();
|
||||
if a == b {
|
||||
Vec::new()
|
||||
} else {
|
||||
wordcraft_engine::cmd::table_mouse::selection_rects(&layout, page_doc(app), &a, &b, app.session.page_hint)
|
||||
}
|
||||
} else if crate::objects::selected(app).is_some() {
|
||||
Vec::new()
|
||||
} else if let Some(range) = app.session.cell_range() {
|
||||
wordcraft_engine::cmd::table_mouse::cell_rects(&layout, &app.session.doc, range)
|
||||
@@ -1579,7 +1641,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
if !math_caret
|
||||
&& let Some(c) = layout.caret_on(&app.session.sel.focus, app.session.page_hint)
|
||||
&& let Some(c) = layout.caret_on(&preview_sel(app).unwrap_or(&app.session.sel).focus, app.session.page_hint)
|
||||
&& let Some(pr) = rects.get(c.page)
|
||||
{
|
||||
let scale = layout.pages.get(c.page).map_or(geo.scale, |page| page_screen_scale(*pr, page, geo.scale));
|
||||
@@ -1632,7 +1694,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
[pos2(x.round() + 0.5, y0), pos2(x.round() + 0.5, y1)]
|
||||
};
|
||||
// The caret glides and fades like Word's (`caret_glide`).
|
||||
let shown = app.session.sel.is_collapsed() && focused;
|
||||
let shown = preview_sel(app).unwrap_or(&app.session.sel).is_collapsed() && focused;
|
||||
let look = crate::caret_glide::look(app, ui, content_origin, &geo, bar, shown, since, on);
|
||||
if shown && look.opacity > 0.0 {
|
||||
let color = preedit_color.unwrap_or_else(|| caret_color(app));
|
||||
@@ -3645,6 +3707,89 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::table_resize::tests::{H, cell, screen, steps, table_window};
|
||||
|
||||
/// #3931: while Insert › Table's grid asks for a preview the page is laid out with that
|
||||
/// table; the document, its revision and undo history stay as they were, and without a
|
||||
/// request the page is the document's own again.
|
||||
#[test]
|
||||
fn the_page_shows_the_table_the_grid_previews() {
|
||||
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Lanterns")), crate::Services::default());
|
||||
let cells = |l: &DocLayout| l.index.keys().filter(|(st, p)| *st == StoryRef::Body && p.0.len() == 4).count();
|
||||
let (doc, rev) = (app.session.doc.clone(), app.session.rev());
|
||||
app.canvas.table_preview = Some((2, 3));
|
||||
assert_eq!(cells(&shown_layout(&mut app)), 6);
|
||||
assert_eq!(app.canvas.table_preview, None, "a request lasts one frame");
|
||||
assert_eq!((&app.session.doc, app.session.rev(), app.session.can_undo()), (&doc, rev, false));
|
||||
app.canvas.table_preview = Some((2, 3));
|
||||
let again = shown_layout(&mut app);
|
||||
app.canvas.table_preview = Some((2, 3));
|
||||
assert!(std::sync::Arc::ptr_eq(&again, &shown_layout(&mut app)), "a still pointer reuses the preview");
|
||||
assert_eq!(cells(&shown_layout(&mut app)), 0, "no request: the document's own page");
|
||||
}
|
||||
|
||||
/// #3931 review: the preview's pages are drawn from the copy they were laid out from. The
|
||||
/// table moves "Kites" down a block, so the document's own block there is not its paragraph.
|
||||
#[test]
|
||||
fn the_previewed_pages_are_drawn_from_the_previewed_document() {
|
||||
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Lanterns\nKites")), crate::Services::default());
|
||||
app.session.run("caret.end", &json!({})).unwrap();
|
||||
app.canvas.table_preview = Some((2, 2));
|
||||
let layout = shown_layout(&mut app);
|
||||
let text = |doc: &wordcraft_doc::Document, story, path| doc.para(story, path).map(|p| p.text.clone());
|
||||
let mut kites = 0;
|
||||
for it in layout.pages.iter().flat_map(|p| &p.items) {
|
||||
let Placed::Lines { story, path, .. } = it else { continue };
|
||||
let shown = text(page_doc(&app), *story, path);
|
||||
assert!(shown.is_some(), "{path:?} names a paragraph of the drawn document");
|
||||
kites += usize::from(shown.as_deref() == Some("Kites"));
|
||||
}
|
||||
assert_eq!(kites, 1, "Kites is drawn as itself");
|
||||
assert_eq!(shown_layout(&mut app).index, app.session.layout().index);
|
||||
assert!(std::ptr::eq(page_doc(&app), &app.session.doc), "no preview: the document's own pages");
|
||||
}
|
||||
|
||||
/// #3931 review: with a selection the table replaces it in the copy, so the page draws the
|
||||
/// copy's caret, in the table, and no selection: the document's selection names text that has
|
||||
/// moved in the preview's layout.
|
||||
#[test]
|
||||
fn the_preview_draws_the_copy_caret_and_selection() {
|
||||
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Lanterns\nKites")), crate::Services::default());
|
||||
app.session.sel = wordcraft_engine::Selection { anchor: Pos::body(0, 3), focus: Pos::body(1, 2) };
|
||||
app.canvas.table_preview = Some((2, 2));
|
||||
let layout = shown_layout(&mut app);
|
||||
let sel = preview_sel(&app).expect("the preview's selection").clone();
|
||||
assert!(sel.is_collapsed(), "the table took the selection's place");
|
||||
assert_ne!(sel, app.session.sel);
|
||||
let c = layout.caret_on(&sel.focus, 0).expect("the caret is on the preview's page");
|
||||
assert!(layout.pages[c.page].items.iter().any(|it| matches!(it, Placed::Lines { path, .. } if path.0.len() == 4 && *path == sel.focus.path)));
|
||||
assert!(sel.focus.path.cell().is_some(), "the caret is in the table");
|
||||
assert_eq!(shown_layout(&mut app).index, app.session.layout().index);
|
||||
assert!(preview_sel(&app).is_none(), "no preview: the document's own selection");
|
||||
}
|
||||
|
||||
/// #3931 review: the preview is laid out again when the caret moves or the view changes with
|
||||
/// the grid still hovered, so the page shows the table where a click would put it, at the
|
||||
/// view's width.
|
||||
#[test]
|
||||
fn the_preview_follows_the_caret_and_the_view() {
|
||||
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Lanterns\nKites")), crate::Services::default());
|
||||
let tables =
|
||||
|l: &DocLayout| l.index.keys().filter(|(st, p)| *st == StoryRef::Body && p.0.len() == 4).map(|(_, p)| p.0[0]).collect::<HashSet<u32>>();
|
||||
app.session.run("caret.docEnd", &json!({})).unwrap();
|
||||
app.canvas.table_preview = Some((2, 2));
|
||||
let at_end = tables(&shown_layout(&mut app));
|
||||
app.session.run("caret.docStart", &json!({})).unwrap();
|
||||
app.canvas.table_preview = Some((2, 2));
|
||||
let at_start = tables(&shown_layout(&mut app));
|
||||
assert_ne!(at_start, at_end, "the caret moved: so did the table");
|
||||
let (_, _, fresh) = app.session.preview_layout("insert.table", &json!({"rows": 2, "cols": 2})).unwrap();
|
||||
assert_eq!(at_start, tables(&fresh));
|
||||
app.session.view.mode = wordcraft_layout::ViewMode::Web;
|
||||
app.session.view.web_width = 300.0;
|
||||
app.canvas.table_preview = Some((2, 2));
|
||||
let web = shown_layout(&mut app);
|
||||
assert_eq!(web.pages[0].w, app.session.layout().pages[0].w, "laid out at the Web view's width");
|
||||
}
|
||||
|
||||
/// A window-following layout size (#1876, Read Mode): applied at once on entry or when forced,
|
||||
/// held while the window is dragged, applied once the size has been still for the settle time.
|
||||
#[test]
|
||||
|
||||
@@ -326,9 +326,61 @@ pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>) -> Value {
|
||||
let _ = (&png, path);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if let Err(e) = std::fs::write(path, &png) {
|
||||
return json!({"ok": false, "error": format!("{path}: {e}")});
|
||||
if !path.to_ascii_lowercase().ends_with(".png") {
|
||||
return json!({"ok": false, "error": format!("{path}: a screenshot path must end in .png")});
|
||||
}
|
||||
if let Err(e) = wordcraft_engine::io::write_file(path, &png) {
|
||||
return json!({"ok": false, "error": e});
|
||||
}
|
||||
}
|
||||
json!({"ok": true, "result": {"path": path, "width": w, "height": h}})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod screenshot_tests {
|
||||
use super::save_screenshot;
|
||||
|
||||
struct Cleanup(std::path::PathBuf);
|
||||
|
||||
impl Drop for Cleanup {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_path_must_be_png_and_does_not_follow_symlinks() {
|
||||
let dir = std::env::temp_dir().join(format!("wc-shot-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let _cleanup = Cleanup(dir.clone());
|
||||
let img = egui::ColorImage::filled([2, 2], egui::Color32::RED);
|
||||
|
||||
let txt = dir.join("shot.txt");
|
||||
let r = save_screenshot(&img, txt.to_str());
|
||||
assert_eq!(r["ok"], false);
|
||||
assert!(!txt.exists());
|
||||
|
||||
let png = dir.join("shot.png");
|
||||
let r = save_screenshot(&img, png.to_str());
|
||||
assert_eq!(r["ok"], true);
|
||||
assert!(std::fs::read(&png).unwrap().starts_with(b"\x89PNG"));
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let victim = dir.join("victim.txt");
|
||||
std::fs::write(&victim, "keep").unwrap();
|
||||
let link = dir.join("link.png");
|
||||
std::os::unix::fs::symlink(&victim, &link).unwrap();
|
||||
let _ = save_screenshot(&img, link.to_str());
|
||||
assert_eq!(std::fs::read_to_string(&victim).unwrap(), "keep");
|
||||
}
|
||||
|
||||
let other = dir.join("other");
|
||||
std::fs::create_dir_all(&other).unwrap();
|
||||
let roots = wordcraft_engine::file_access::AutomationRoots::new(None, Some(&other)).unwrap();
|
||||
let outside = dir.join("outside.png");
|
||||
let r = wordcraft_engine::file_access::confine(roots.as_ref(), || save_screenshot(&img, outside.to_str()));
|
||||
assert_eq!(r["ok"], false);
|
||||
assert!(!outside.exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,6 +315,8 @@ pub enum Dialog {
|
||||
offset: f32,
|
||||
/// The selection already has a phonetic guide (Remove is offered).
|
||||
existing: bool,
|
||||
/// Ask the Ruby text box for the keyboard on the first frame (once, so Tab and clicks still move it).
|
||||
focus: bool,
|
||||
},
|
||||
NewStyle {
|
||||
name: String,
|
||||
@@ -1208,6 +1210,7 @@ impl Dialog {
|
||||
size: f("size"),
|
||||
offset: f("offset"),
|
||||
existing: v.get("existing").and_then(Value::as_bool).unwrap_or(false),
|
||||
focus: true,
|
||||
}
|
||||
}
|
||||
"newStyle" => Dialog::NewStyle { name: "Style1".into(), based_on: "Normal".into(), back: false },
|
||||
@@ -1250,7 +1253,7 @@ impl Dialog {
|
||||
filter: RecipientFilter::from_session(m),
|
||||
}
|
||||
} else {
|
||||
let fields: Vec<String> = NEW_LIST_FIELDS.iter().map(|f| f.to_string()).collect();
|
||||
let fields: Vec<String> = NEW_LIST_FIELDS.iter().map(ToString::to_string).collect();
|
||||
let rows = vec![vec![String::new(); fields.len()]];
|
||||
Dialog::RecipientList { fields, rows, message: String::new(), filter: RecipientFilter::default() }
|
||||
}
|
||||
@@ -1353,7 +1356,7 @@ impl Dialog {
|
||||
}
|
||||
}
|
||||
|
||||
/// Grid picker for Insert › Table (hover to size, click to insert).
|
||||
/// Grid picker for Insert › Table (hover to size and preview on the page, click to insert).
|
||||
pub fn table_grid_picker(ui: &mut Ui, app: &mut WordApp) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let id = egui::Id::new("table_picker_hover");
|
||||
@@ -1388,9 +1391,16 @@ pub fn table_grid_picker(ui: &mut Ui, app: &mut WordApp) {
|
||||
}
|
||||
});
|
||||
ui.data_mut(|d| d.insert_temp(id, new_hover));
|
||||
if new_hover != hover {
|
||||
// The page may already be drawn this frame (Focus drops the ribbon over it afterwards).
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
if let Some((r, c)) = clicked {
|
||||
let _ = app.run("insert.table", json!({"rows": r, "cols": c}));
|
||||
ui.close();
|
||||
} else if new_hover.0 > 0 {
|
||||
// Word's Live Preview: the page shows the hovered table until a square is clicked.
|
||||
app.canvas.table_preview = Some(new_hover);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1935,14 +1945,11 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
mixed,
|
||||
..
|
||||
} => {
|
||||
ui.horizontal(|ui| {
|
||||
for (k, name) in ["Indents and Spacing", "Line and Page Breaks", "Asian Typography"].into_iter().enumerate() {
|
||||
if ui.selectable_label(*tab as usize == k, tl!(name)).clicked() {
|
||||
*tab = k as u8;
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
let names = ["Indents and Spacing", "Line and Page Breaks", "Asian Typography"].map(|n| tl!(n).to_string());
|
||||
if let Some(k) = crate::widgets::dialog_tabs(ui, &names, *tab as usize) {
|
||||
*tab = k as u8;
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
match *tab {
|
||||
0 => {
|
||||
ui.label(egui::RichText::new(tl!("General")).font(semibold(12.5)));
|
||||
@@ -2040,15 +2047,14 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
Dialog::Tabs { form } => crate::dialogs_para::tabs(app, ui, form),
|
||||
Dialog::Borders { form } => crate::dialogs_para::borders(app, ui, form),
|
||||
Dialog::Find { query, replace, match_case, whole_word, regex, extra, tab, goto_what, goto_target, message, focus } => {
|
||||
ui.horizontal(|ui| {
|
||||
for (t, label) in [(FindTab::Find, "Find"), (FindTab::Replace, "Replace"), (FindTab::GoTo, "Go To")] {
|
||||
if ui.selectable_label(*tab == t, tl!(label)).clicked() && *tab != t {
|
||||
*tab = t;
|
||||
message.clear();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
const FIND_TABS: [(FindTab, &str); 3] = [(FindTab::Find, "Find"), (FindTab::Replace, "Replace"), (FindTab::GoTo, "Go To")];
|
||||
let names = FIND_TABS.map(|(_, l)| tl!(l).to_string());
|
||||
let current = FIND_TABS.iter().position(|(t, _)| t == tab).unwrap_or(0);
|
||||
if let Some((t, _)) = crate::widgets::dialog_tabs(ui, &names, current).and_then(|k| FIND_TABS.get(k)) {
|
||||
*tab = *t;
|
||||
message.clear();
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
if *tab == FindTab::GoTo {
|
||||
return goto_body(app, ui, goto_what, goto_target, message);
|
||||
}
|
||||
@@ -2491,7 +2497,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
Dialog::Phonetic { base, ruby, align, size, offset, existing } => {
|
||||
Dialog::Phonetic { base, ruby, align, size, offset, existing, focus } => {
|
||||
// Base and ruby text are usually Chinese or Japanese: fonts for them load from the next frame.
|
||||
app.want_system_cjk = true;
|
||||
const ALIGNS: [(&str, &str); 6] = [
|
||||
@@ -2507,7 +2513,11 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
ui.label(egui::RichText::new(base.as_str()).strong());
|
||||
ui.end_row();
|
||||
ui.label(tl!("Ruby text:"));
|
||||
ui.text_edit_singleline(ruby);
|
||||
let r = ui.text_edit_singleline(ruby);
|
||||
// Typing goes to the Ruby text box, not the selected text in the document (#3914).
|
||||
if std::mem::take(focus) {
|
||||
r.request_focus();
|
||||
}
|
||||
ui.end_row();
|
||||
ui.label(tl!("Alignment:"));
|
||||
let shown = ALIGNS.iter().find(|(k, _)| k == align).map_or("Center", |(_, l)| l);
|
||||
@@ -2699,15 +2709,11 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
Dialog::About { tab } => {
|
||||
ui.set_width(660.0);
|
||||
ui.horizontal(|ui| {
|
||||
for (i, l) in ["About", "Contributors", "Models"].into_iter().enumerate() {
|
||||
let i = i as u8;
|
||||
if ui.selectable_label(*tab == i, tl!(l)).clicked() {
|
||||
*tab = i;
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
let names = ["About", "Contributors", "Models"].map(|n| tl!(n).to_string());
|
||||
if let Some(i) = crate::widgets::dialog_tabs(ui, &names, *tab as usize) {
|
||||
*tab = i as u8;
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
match *tab {
|
||||
1 => crate::credits::contributors_ui(ui),
|
||||
2 => crate::credits::models_ui(ui),
|
||||
@@ -4107,6 +4113,44 @@ mod tests {
|
||||
use wordcraft_doc::props::Align;
|
||||
use wordcraft_engine::Session;
|
||||
|
||||
/// #3931: hovering Insert › Table's grid asks the page for a preview of that table (rows by
|
||||
/// columns) and changes nothing; leaving the grid drops it; a click inserts the table with no
|
||||
/// preview left over for the page to show on top of it.
|
||||
#[test]
|
||||
fn the_table_grid_previews_the_hovered_table_until_the_click() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let a = WordApp::new(Session::new(wordcraft_doc::Document::from_text("Lanterns")), crate::Services::default());
|
||||
let mut h =
|
||||
egui_kittest::Harness::builder().with_size(vec2(400.0, 400.0)).build_ui_state(|ui, app: &mut WordApp| table_grid_picker(ui, app), a);
|
||||
h.run_steps(2);
|
||||
let spacing = h.ctx.global_style().spacing.clone();
|
||||
let top = h.get_by_label("Insert Table").rect();
|
||||
// Squares are 16 points with 2 between, in grid columns at least `interact_size` wide:
|
||||
// the middle of row 3, column 4.
|
||||
let col = spacing.interact_size.x.max(16.0) + 2.0;
|
||||
let at = egui::pos2(top.left() + 3.0 * col + 8.0, top.bottom() + spacing.item_spacing.y + 2.0 * 18.0 + 8.0);
|
||||
h.event(egui::Event::PointerMoved(at));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().canvas.table_preview, Some((3, 4)), "three rows of four columns");
|
||||
assert!(h.query_by_label("4×3 Table").is_some(), "the label reads columns × rows");
|
||||
assert!(h.state().session.doc.body.iter().all(|b| b.as_table().is_none()) && !h.state().session.can_undo());
|
||||
h.event(egui::Event::PointerMoved(egui::pos2(390.0, 390.0)));
|
||||
h.state_mut().canvas.table_preview = None;
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().canvas.table_preview, None, "off the grid nothing is previewed");
|
||||
h.event(egui::Event::PointerMoved(at));
|
||||
h.run_steps(2);
|
||||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.step();
|
||||
// The page takes the request every frame; here nothing does.
|
||||
h.state_mut().canvas.table_preview = None;
|
||||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
h.step();
|
||||
let t = h.state().session.doc.body.iter().find_map(|b| b.as_table()).expect("the click inserts the table");
|
||||
assert_eq!((t.rows.len(), t.cols()), (3, 4));
|
||||
assert_eq!(h.state().canvas.table_preview, None, "the inserted table isn't previewed again");
|
||||
}
|
||||
|
||||
/// Go To's page box (#712): a number, +n / -n from the current page (a typographic minus
|
||||
/// too), empty for the next page; anything else is refused.
|
||||
#[test]
|
||||
@@ -4311,6 +4355,30 @@ mod tests {
|
||||
assert_eq!(serde_json::to_value(&h.state().session.doc).unwrap(), before, "search does not change the document");
|
||||
}
|
||||
|
||||
/// #3914: the Phonetic Guide dialog opens with the keyboard in its Ruby text box, so typing
|
||||
/// fills the box and does not replace the selected text in the document.
|
||||
#[test]
|
||||
fn phonetic_guide_ruby_box_takes_the_keyboard_on_open() {
|
||||
let mut app = app_with("Unchanged document");
|
||||
app.dialog = Some(Dialog::Phonetic {
|
||||
base: "Unchanged".into(),
|
||||
ruby: String::new(),
|
||||
align: "center".into(),
|
||||
size: 12.0,
|
||||
offset: 0.0,
|
||||
existing: false,
|
||||
focus: true,
|
||||
});
|
||||
let mut h = app_harness(app);
|
||||
h.run_steps(4);
|
||||
h.event(egui::Event::Text("ab".into()));
|
||||
h.run_steps(2);
|
||||
match h.state().dialog.as_ref() {
|
||||
Some(Dialog::Phonetic { ruby, .. }) => assert_eq!(ruby, "ab", "the typed text reaches the Ruby text box"),
|
||||
_ => panic!("the Phonetic Guide dialog is still open"),
|
||||
}
|
||||
}
|
||||
|
||||
/// #2986: a box that keeps the keyboard asks for focus only while it lacks it. `request_focus`
|
||||
/// always interrupts IME; on X11 that recreates the XIM context, and Search asking every frame
|
||||
/// left fcitx taking no keys at all. Once the box has focus, no frame interrupts, and typing lands;
|
||||
|
||||
@@ -242,7 +242,7 @@ pub struct CaptionForm {
|
||||
|
||||
impl CaptionForm {
|
||||
pub fn read(app: &WordApp) -> CaptionForm {
|
||||
let mut labels: Vec<String> = BUILT_IN_LABELS.iter().map(|l| l.to_string()).collect();
|
||||
let mut labels: Vec<String> = BUILT_IN_LABELS.iter().map(ToString::to_string).collect();
|
||||
for l in &app.ui.caption_labels {
|
||||
if !labels.contains(l) {
|
||||
labels.push(l.clone());
|
||||
@@ -937,7 +937,7 @@ pub struct FiguresForm {
|
||||
|
||||
impl FiguresForm {
|
||||
pub fn read(app: &WordApp) -> FiguresForm {
|
||||
let mut labels: Vec<String> = BUILT_IN_LABELS.iter().map(|l| l.to_string()).collect();
|
||||
let mut labels: Vec<String> = BUILT_IN_LABELS.iter().map(ToString::to_string).collect();
|
||||
for l in app.ui.caption_labels.iter().cloned().chain(wordcraft_engine::cmd::citations::caption_labels(&app.session)) {
|
||||
if !labels.contains(&l) {
|
||||
labels.push(l);
|
||||
|
||||
@@ -47,7 +47,8 @@ pub(crate) struct Listing {
|
||||
|
||||
/// Whether `path` has one of `exts` (case-insensitive); `*` takes any file.
|
||||
pub(crate) fn matches(path: &Path, exts: &[String]) -> bool {
|
||||
exts.iter().any(|e| e == "*") || path.extension().and_then(|e| e.to_str()).is_some_and(|e| exts.iter().any(|x| x.eq_ignore_ascii_case(e)))
|
||||
exts.iter().any(|e| e == "*")
|
||||
|| path.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| exts.iter().any(|x| x.eq_ignore_ascii_case(e)))
|
||||
}
|
||||
|
||||
/// The subfolders and the files with one of `exts` in `dir`, at most `cap` of them, folders first
|
||||
@@ -151,7 +152,7 @@ impl FileBrowser {
|
||||
(String::new(), open_filters(purpose).iter().map(|(l, e)| ((*l).to_string(), e.iter().map(|x| (*x).to_string()).collect())).collect())
|
||||
}
|
||||
FileDialogRequest::Save { name, .. } => {
|
||||
let ext = Path::new(name).extension().and_then(|e| e.to_str()).filter(|e| !e.is_empty());
|
||||
let ext = Path::new(name).extension().and_then(std::ffi::OsStr::to_str).filter(|e| !e.is_empty());
|
||||
let mut f: Vec<(String, Vec<String>)> = ext.map(|e| (format!("*.{e}"), vec![e.to_string()])).into_iter().collect();
|
||||
f.push(("All files".into(), vec!["*".into()]));
|
||||
(name.clone(), f)
|
||||
|
||||
@@ -434,10 +434,10 @@ fn letters_for(app: &WordApp, tab: &str) -> Vec<String> {
|
||||
v.extend(own);
|
||||
let words: Vec<&str> = ctl.tip.split_whitespace().filter(|w| w.chars().next().is_some_and(|c| c.is_ascii_alphanumeric())).collect();
|
||||
if let Some(w) = words.first() {
|
||||
v.extend(w.chars().filter(|c| c.is_ascii_alphanumeric()).map(|c| c.to_ascii_uppercase()));
|
||||
v.extend(w.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_uppercase()));
|
||||
}
|
||||
if words.len() > 1 {
|
||||
v.extend(words.iter().map(|w| w.chars().next().unwrap_or(' ')).filter(|c| c.is_ascii_alphanumeric()).map(|c| c.to_ascii_uppercase()));
|
||||
v.extend(words.iter().map(|w| w.chars().next().unwrap_or(' ')).filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_uppercase()));
|
||||
}
|
||||
v
|
||||
};
|
||||
@@ -486,7 +486,7 @@ fn letters_for(app: &WordApp, tab: &str) -> Vec<String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
out.into_iter().map(|o| o.unwrap_or_default()).collect()
|
||||
out.into_iter().map(Option::unwrap_or_default).collect()
|
||||
}
|
||||
|
||||
/// Pure letter assignment `(tab letters, per-tab control letters)`, for tests without a window.
|
||||
|
||||
@@ -2568,7 +2568,7 @@ impl WordApp {
|
||||
/// recipient lists (CSV/TSV, spreadsheets, or a text file asked for as one) load as recipients.
|
||||
fn drain_inbox(&mut self) {
|
||||
let Some(inbox) = self.services.inbox.clone() else { return };
|
||||
let files = std::mem::take(&mut *inbox.lock().unwrap_or_else(|e| e.into_inner()));
|
||||
let files = std::mem::take(&mut *inbox.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
|
||||
let mut placed_picture = false;
|
||||
for (name, bytes) in files {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
@@ -2602,7 +2602,7 @@ impl WordApp {
|
||||
/// otherwise so the focused field's own paste applies (a text field ignores a picture).
|
||||
fn drain_pasted(&mut self) {
|
||||
let Some(pasted) = self.services.pasted.clone() else { return };
|
||||
let pictures = std::mem::take(&mut *pasted.lock().unwrap_or_else(|e| e.into_inner()));
|
||||
let pictures = std::mem::take(&mut *pasted.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
|
||||
let target = paste_target(self.canvas.focused, self.ui.backstage, self.dialog.is_some() || self.file_dialog_open());
|
||||
for (_, bytes) in pictures {
|
||||
if !target {
|
||||
|
||||
@@ -57,7 +57,7 @@ pub fn png_from_rgba(width: usize, height: usize, rgba: Vec<u8>) -> Result<Vec<u
|
||||
}
|
||||
|
||||
fn is_picture_file(path: &Path) -> bool {
|
||||
path.extension().and_then(|e| e.to_str()).is_some_and(|e| IMAGE_EXTS.iter().any(|x| x.eq_ignore_ascii_case(e)))
|
||||
path.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| IMAGE_EXTS.iter().any(|x| x.eq_ignore_ascii_case(e)))
|
||||
}
|
||||
|
||||
/// Pasted text that is only paths or `file://` addresses of picture files — what file managers
|
||||
|
||||
@@ -1299,6 +1299,48 @@ pub fn icon_menu_item(ui: &mut Ui, icon: &str, text: &str, shortcut: &str, min_w
|
||||
resp
|
||||
}
|
||||
|
||||
/// A strip of dialog tabs. The active tab joins the page below it.
|
||||
/// Returns the index of the tab the user clicked.
|
||||
pub fn dialog_tabs(ui: &mut Ui, labels: &[String], selected: usize) -> Option<usize> {
|
||||
const H: f32 = 26.0;
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let page = ui.visuals().window_fill;
|
||||
let need: f32 = labels.iter().map(|l| ui.painter().layout_no_wrap(l.clone(), regular(12.5), t.text).size().x + 27.0).sum::<f32>() + 1.0;
|
||||
ui.set_min_width(need);
|
||||
let origin = ui.cursor().min;
|
||||
let width = ui.available_width();
|
||||
let base_y = origin.y + H;
|
||||
let base = ui.painter().add(egui::Shape::Noop);
|
||||
let top = CornerRadius { nw: 5, ne: 5, sw: 0, se: 0 };
|
||||
let mut x = origin.x;
|
||||
let mut clicked = None;
|
||||
for (i, label) in labels.iter().enumerate() {
|
||||
let on = i == selected;
|
||||
let galley = ui.painter().layout_no_wrap(label.clone(), regular(12.5), t.text);
|
||||
let w = galley.size().x + 28.0;
|
||||
let r = Rect::from_min_max(pos2(x, origin.y + 2.0), pos2(x + w, base_y));
|
||||
let resp = ui.interact(r, ui.id().with(("dialog_tab", i)), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, on, label));
|
||||
let hover = ui.ctx().animate_bool_responsive(resp.id.with("hover"), resp.hovered());
|
||||
let fill = if on { page } else { page.lerp_to_gamma(t.border, 0.14 + 0.1 * hover) };
|
||||
let p = ui.painter();
|
||||
let shown = if on { r } else { Rect::from_min_max(pos2(r.min.x, r.min.y + 2.0), r.max) };
|
||||
p.rect(shown, top, fill, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
if on {
|
||||
p.rect_filled(Rect::from_min_max(pos2(r.min.x + 1.0, base_y - 1.0), pos2(r.max.x - 1.0, base_y + 1.0)), 0.0, page);
|
||||
}
|
||||
let color = if on { t.text } else { t.text.gamma_multiply(0.8) };
|
||||
p.galley(pos2(r.center().x - galley.size().x / 2.0, r.center().y - galley.size().y / 2.0), galley, color);
|
||||
if resp.clicked() && !on {
|
||||
clicked = Some(i);
|
||||
}
|
||||
x += w - 1.0;
|
||||
}
|
||||
ui.allocate_space(vec2(width, H + 1.0));
|
||||
ui.painter().set(base, egui::Shape::line_segment([pos2(origin.x, base_y), pos2(origin.x + width, base_y)], Stroke::new(1.0, t.border)));
|
||||
clicked
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+2
-1
File diff suppressed because one or more lines are too long
+6
-1
@@ -19,6 +19,7 @@ workspace_version() {
|
||||
/^\[/ { in_pkg = ($0 == "[workspace.package]") ; next }
|
||||
in_pkg && $1 == "version" { gsub(/[" ]/, "", $3); print $3; exit }
|
||||
' "$ROOT/Cargo.toml"
|
||||
return $?
|
||||
}
|
||||
|
||||
VERSION="${WORDCRAFT_VERSION:-$(workspace_version)}"
|
||||
@@ -42,6 +43,7 @@ export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}"
|
||||
# Emit a GitHub Actions warning (plain stderr outside Actions).
|
||||
warn() {
|
||||
if [[ -n "${GITHUB_ACTIONS:-}" ]]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Copy licence and readme files that exist into a package directory.
|
||||
@@ -51,6 +53,7 @@ copy_docs() {
|
||||
if [[ -f "$ROOT/$f" ]]; then cp "$ROOT/$f" "$dest/"; fi
|
||||
done
|
||||
copy_font_licences "$dest"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Builds made with craft-fonts (CRAFT_FONTS_DIR, set by the release workflow) embed its fonts, so
|
||||
@@ -65,5 +68,7 @@ copy_font_licences() {
|
||||
|
||||
# Portable SHA-256 of a file (prints just the hash).
|
||||
sha256() {
|
||||
if command -v sha256sum >/dev/null; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi
|
||||
local file="$1"
|
||||
if command -v sha256sum >/dev/null; then sha256sum "$file" | cut -d' ' -f1; else shasum -a 256 "$file" | cut -d' ' -f1; fi
|
||||
return $?
|
||||
}
|
||||
|
||||
+5
-1
@@ -21,7 +21,11 @@ MAC="$TMP/mac.svg"
|
||||
sed 's/viewBox="0 0 512 512"/viewBox="-62.136 -62.136 636.272 636.272"/' "$SVG" >"$MAC"
|
||||
grep -q 'viewBox="-62.136' "$MAC" || { echo "error: expected viewBox=\"0 0 512 512\" in $SVG" >&2; exit 1; }
|
||||
|
||||
render() { resvg -w "$2" -h "$2" "$1" "$3" </dev/null; }
|
||||
render() {
|
||||
local src="$1" size="$2" dst="$3"
|
||||
resvg -w "$size" -h "$size" "$src" "$dst" </dev/null
|
||||
return 0
|
||||
}
|
||||
|
||||
render "$SVG" 1024 "$DIR/wordcraft-1024.png"
|
||||
# Runtime window/Dock icon on macOS (embedded by apps/wordcraft); other platforms use hicolor 256.
|
||||
|
||||
@@ -75,7 +75,7 @@ if command -v appstreamcli >/dev/null; then
|
||||
appstreamcli validate --no-net --explain "$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml"
|
||||
fi
|
||||
|
||||
has() { case " $FORMATS " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
|
||||
has() { local item="$1"; case " $FORMATS " in *" $item "*) return 0 ;; *) return 1 ;; esac; }
|
||||
|
||||
# ---- .tar.gz ------------------------------------------------------------------------------------
|
||||
if has tar; then
|
||||
@@ -119,13 +119,15 @@ if has appimage; then
|
||||
fi
|
||||
# Downloads $1 to $3 once and checks it against SHA-256 $2, the cached copy again on every run.
|
||||
fetch_pinned() {
|
||||
if [[ ! -x "$3" ]]; then
|
||||
curl --proto '=https' -fsSL -o "$3.download" "$1"
|
||||
printf '%s %s\n' "$2" "$3.download" | sha256sum -c -
|
||||
chmod 755 "$3.download"
|
||||
mv "$3.download" "$3"
|
||||
local url="$1" expected_sha="$2" target="$3"
|
||||
if [[ ! -x "$target" ]]; then
|
||||
curl --proto '=https' -fsSL -o "$target.download" "$url"
|
||||
printf '%s %s\n' "$expected_sha" "$target.download" | sha256sum -c -
|
||||
chmod 755 "$target.download"
|
||||
mv "$target.download" "$target"
|
||||
fi
|
||||
printf '%s %s\n' "$2" "$3" | sha256sum -c -
|
||||
printf '%s %s\n' "$expected_sha" "$target" | sha256sum -c -
|
||||
return 0
|
||||
}
|
||||
APPDIR="$WORK/WordCraft.AppDir"
|
||||
cp -R "$STAGE" "$APPDIR"
|
||||
|
||||
@@ -143,18 +143,20 @@ rm -f "$DMG"
|
||||
# Retries an hdiutil verb that attaches a device: on CI runners it fails now and then with
|
||||
# "Resource busy". Usage: hdiutil_retry <verb> <args...>; five tries with growing pauses.
|
||||
hdiutil_retry() {
|
||||
local verb="$1"
|
||||
local attempt
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if hdiutil "$@"; then
|
||||
return 0
|
||||
fi
|
||||
if [[ "$attempt" = 5 ]]; then
|
||||
echo "error: hdiutil $1 failed 5 times" >&2
|
||||
echo "error: hdiutil $verb failed 5 times" >&2
|
||||
return 1
|
||||
fi
|
||||
warn "hdiutil $1 failed (attempt $attempt of 5); retrying in $((attempt * 10))s"
|
||||
warn "hdiutil $verb failed (attempt $attempt of 5); retrying in $((attempt * 10))s"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
# `hdiutil create -srcfolder` copies the staged files as they are. The previous
|
||||
# `makehybrid -hfs` + `convert` gave every file and folder in the image a com.apple.FinderInfo
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ const ASSET_EXT: &[&str] = &[
|
||||
const ASSET_DIRS: &[&str] = &["assets/", "docs/images/", "examples/"];
|
||||
|
||||
pub fn is_asset(path: &str) -> bool {
|
||||
let ext = Path::new(path).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).unwrap_or_default();
|
||||
let ext = Path::new(path).extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).unwrap_or_default();
|
||||
ASSET_DIRS.iter().any(|d| path.starts_with(d)) || ASSET_EXT.contains(&ext.as_str())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user