mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
Add craft-flow: the flow model the text formats map to (#1025)
* Add craft-wordmodel: WordCraft's document model without its editing code WordCraft's crates/doc (wordcraft-doc, at 07f97737) moved as it is, so the DOCX, RTF, ODT and other text-format crates can read into and write from one model. Module paths, type names and serde attributes are unchanged: an app that re-exports the crate keeps its native file format. Left in the app: range editing (edit.rs), IME text and equation editing. Not model, and already in craft-libs: code pages (craft-rtf), the ZIP entry-count check (craft-opc), the compound-file guard (craft-cfb). The paragraph walks moved to walk.rs and the unit helpers of wordcraft-geom to geom.rs. * craft-wordmodel: math_tree, the arguments of an equation node and a walk over its runs The DOCX reader and writer walk an equation's runs. The walk and the child accessors it uses were in WordCraft's equation editor (math_edit.rs), which stays in the app; they are structure, not editing. * craft-wordmodel: keep the document operations (edit.rs) with the model The DOCX and RTF readers use them: alternative-format chunks are merged into a document with adopt_blocks, and a reader formats ranges with format_range and inserts objects. They operate on the document, not on a view, so they belong beside it. The tests that drove them come back with them; the paragraph walks stay in walk.rs and edit re-exports them. base64 0.23, as WordCraft and craft-offcrypto use. * Add craft-flow: the flow model the text formats map to, on craft-wordmodel WordCraft's crates/formats/src/model (07f97737) moved as it is: Flow, FBlock, Para, Inline, Fmt, tables and cells, pictures, to_doc and from_doc, picture sniffing, colours, base64 and the Dropped report. Only the paths changed (wordcraft_doc is craft_wordmodel); the report helpers are public so the format crates can call them; the image re-encoding helper stays with WordCraft's RTF writer.
This commit is contained in:
1 parent
336619e3e4
commit
baf6bd58f0
13 files changed
+4108
No files matched your search
Generated
+9
@@ -1166,6 +1166,15 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-flow"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"craft-wordmodel",
|
||||
"image",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-font-catalog"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -50,6 +50,7 @@ craft-font-catalog = { path = "crates/font-catalog" }
|
||||
craft-font-manager = { path = "crates/font-manager" }
|
||||
craft-font-store = { path = "crates/font-store" }
|
||||
craft-gainmap = { path = "crates/gainmap" }
|
||||
craft-flow = { path = "crates/flow" }
|
||||
craft-gcode = { path = "crates/gcode" }
|
||||
craft-gif = { path = "crates/gif" }
|
||||
craft-hdr = { path = "crates/hdr" }
|
||||
|
||||
@@ -106,6 +106,7 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
and channel names, the channels that make a colour picture, and Cryptomatte IDs, metadata keys,
|
||||
manifests and rank channels. The optional `decode` feature adds bounded part listing, flat
|
||||
decoding (scan lines, tiles, every compression of the `exr` crate) and writing.
|
||||
- [`craft-flow`](crates/flow/README.md): the flow model every text format maps to (paragraphs with a kind, lists and formatted spans; tables; pictures, fields, notes, equations), converted to and from a craft-wordmodel `Document`.
|
||||
- [`craft-font-catalog`](crates/font-catalog/README.md): the installed fonts on every platform
|
||||
(font folders, fontconfig and Flatpak host folders, CoreText, DirectWrite, the font store),
|
||||
lookup by family, style, localized, legacy, full and PostScript names, family loads shared
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "craft-flow"
|
||||
description = "The flow model every text format maps to: paragraphs with a kind, list membership and formatted spans, tables of cells, pictures, notes and fields; converted to and from a craft-wordmodel Document."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-wordmodel = { workspace = true }
|
||||
log = "0.4"
|
||||
# Picture formats and pixel sizes, read from the headers.
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp"] }
|
||||
@@ -0,0 +1,31 @@
|
||||
# craft-flow
|
||||
|
||||
The model every text format maps to and from: a `Flow` is blocks, a block a paragraph (`Para`:
|
||||
a `Kind` — normal, heading, title, quote, code line, rule — list membership, alignment, spacing
|
||||
and formatted `Inline` spans: text, pictures, links, bookmarks, fields, notes, equations, ruby,
|
||||
charts, shapes) or a table (`FTable` of `Cell`s holding blocks), with the document's `Meta`.
|
||||
|
||||
`to_doc` and `from_doc` convert a flow to and from a `craft_wordmodel::Document`. A format reader
|
||||
builds a flow and calls `to_doc`; a writer calls `from_doc` (or `from_doc_with` and `FlowOpts` for
|
||||
what that format keeps: fields, boxes, paragraph formatting, tracked changes) and walks the flow.
|
||||
Formats that keep more than the flow holds (ODT) carry it in the optional fields (`extra`,
|
||||
`kept`, …), which the plain formats leave empty.
|
||||
|
||||
```rust
|
||||
use craft_flow::{FBlock, Flow, Fmt, Inline, Kind, Para, from_doc, to_doc};
|
||||
|
||||
let p = Para { kind: Kind::Heading(1), inlines: vec![Inline::Text("Title".into(), Fmt::default())], ..Default::default() };
|
||||
let doc = to_doc(&Flow { blocks: vec![FBlock::Para(p)], ..Default::default() });
|
||||
let back = from_doc(&doc);
|
||||
assert!(matches!(&back.blocks[..], [FBlock::Para(p)] if p.kind == Kind::Heading(1) && p.text() == "Title"));
|
||||
```
|
||||
|
||||
Also here: picture sniffing and sizing (`sniff_image`, `image_px`, `make_img`), CSS colours
|
||||
(`parse_color`), base64 and `data:` URIs, list labels (`ListCounter`), and the one-warning-per-kind
|
||||
report of what an importer leaves out to stay within a limit (`Dropped`, `begin_import`,
|
||||
`dropped`).
|
||||
|
||||
## Provenance
|
||||
|
||||
WordCraft's `crates/formats/src/model` (at `07f97737`), moved as it is onto craft-wordmodel.
|
||||
Image decoding for re-encoding (`decode_bounded`) stays with the RTF writer that needs it.
|
||||
@@ -0,0 +1,196 @@
|
||||
use super::Rgb;
|
||||
|
||||
fn hsl_to_rgb(h: f64, s: f64, l: f64) -> Rgb {
|
||||
let h = (h / 360.0).rem_euclid(1.0);
|
||||
let s = s.clamp(0.0, 1.0);
|
||||
let l = l.clamp(0.0, 1.0);
|
||||
if s == 0.0 {
|
||||
let v = (l * 255.0).round().clamp(0.0, 255.0) as u8;
|
||||
return Rgb(v, v, v);
|
||||
}
|
||||
let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
|
||||
let p = 2.0 * l - q;
|
||||
let hue_to_rgb = |mut t: f64| -> f64 {
|
||||
if t < 0.0 {
|
||||
t += 1.0;
|
||||
}
|
||||
if t > 1.0 {
|
||||
t -= 1.0;
|
||||
}
|
||||
if t < 1.0 / 6.0 {
|
||||
return p + (q - p) * 6.0 * t;
|
||||
}
|
||||
if t < 1.0 / 2.0 {
|
||||
return q;
|
||||
}
|
||||
if t < 2.0 / 3.0 {
|
||||
return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
|
||||
}
|
||||
p
|
||||
};
|
||||
let r = (hue_to_rgb(h + 1.0 / 3.0) * 255.0).round().clamp(0.0, 255.0) as u8;
|
||||
let g = (hue_to_rgb(h) * 255.0).round().clamp(0.0, 255.0) as u8;
|
||||
let b = (hue_to_rgb(h - 1.0 / 3.0) * 255.0).round().clamp(0.0, 255.0) as u8;
|
||||
Rgb(r, g, b)
|
||||
}
|
||||
|
||||
/// Composite `rgb` over the white page a paste lands on, at CSS alpha `a` (0..1): `rgba(0, 0, 0,
|
||||
/// 0.45)` reads like the rgb(140, 140, 140) it looks like on the site, instead of solid black.
|
||||
fn over_white(r: u8, g: u8, b: u8, a: f32) -> Rgb {
|
||||
let mix = |c: u8| (c as f32 * a + 255.0 * (1.0 - a)).round().clamp(0.0, 255.0) as u8;
|
||||
Rgb(mix(r), mix(g), mix(b))
|
||||
}
|
||||
|
||||
/// The alpha of a CSS colour's `/ alpha` or 4th-value part, where present: 1.0 (opaque) otherwise.
|
||||
fn css_alpha(after: Option<&str>) -> f32 {
|
||||
let Some(a) = after else { return 1.0 };
|
||||
let a = a.trim();
|
||||
// CSS Colour 4's `none` is a missing component, and a missing alpha is transparent.
|
||||
if a.eq_ignore_ascii_case("none") {
|
||||
return 0.0;
|
||||
}
|
||||
// A value that is not a finite number (`nan`, `inf`) is no alpha at all: clamp would keep
|
||||
// NaN, and `over_white` would turn the colour black.
|
||||
let parse = |t: &str| t.parse::<f32>().ok().filter(|f| f.is_finite());
|
||||
if let Some(p) = a.strip_suffix('%') {
|
||||
return parse(p.trim()).map(|f| f.clamp(0.0, 100.0) / 100.0).unwrap_or(1.0);
|
||||
}
|
||||
parse(a).map(|f| f.clamp(0.0, 1.0)).unwrap_or(1.0)
|
||||
}
|
||||
|
||||
/// Parse a CSS / HTML colour: `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb(...)`, `hsl(...)` or a basic name.
|
||||
/// A colour with alpha is composited over white: the model keeps opaque colours, and a paste lands
|
||||
/// on a white page.
|
||||
pub fn parse_color(s: &str) -> Option<Rgb> {
|
||||
let s = s.trim().to_ascii_lowercase();
|
||||
if let Some(h) = s.strip_prefix('#') {
|
||||
if (h.len() == 3 || h.len() == 4) && h.is_ascii() {
|
||||
let d = |i: usize| h.get(i..i + 1).and_then(|x| u8::from_str_radix(x, 16).ok()).map(|v| v * 17);
|
||||
let (r, g, b) = (d(0)?, d(1)?, d(2)?);
|
||||
// A 4th digit is the alpha, composited over the page like `rgba(...)`'s is.
|
||||
let a = if h.len() == 4 { d(3)? as f32 / 255.0 } else { 1.0 };
|
||||
return Some(over_white(r, g, b, a));
|
||||
}
|
||||
if h.len() == 8 && h.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
let p = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok());
|
||||
return Some(over_white(p(0)?, p(2)?, p(4)?, p(6)? as f32 / 255.0));
|
||||
}
|
||||
return Rgb::parse(h);
|
||||
}
|
||||
if let Some(inner) = s.strip_prefix("rgb(").or_else(|| s.strip_prefix("rgba(")) {
|
||||
let inner = inner.trim_end_matches(')').trim();
|
||||
let (rgb_part, alpha_part) = inner.split_once('/').map_or((inner, None), |(a, b)| (a, Some(b)));
|
||||
// Legacy comma syntax carries the alpha as a 4th value (`rgba(0, 0, 0, 0.45)`).
|
||||
let parts: Vec<&str> = if rgb_part.contains(',') { rgb_part.split(',').collect() } else { rgb_part.split_whitespace().collect() };
|
||||
let alpha = match alpha_part {
|
||||
Some(a) => css_alpha(Some(a)),
|
||||
None if parts.len() >= 4 => css_alpha(Some(parts[3])),
|
||||
_ => 1.0,
|
||||
};
|
||||
let v: Vec<u8> = parts
|
||||
.iter()
|
||||
.take(3)
|
||||
.filter_map(|x| {
|
||||
let x = x.trim();
|
||||
if let Some(p) = x.strip_suffix('%') {
|
||||
p.trim().parse::<f32>().ok().map(|f| (f.clamp(0.0, 100.0) * 2.55).round() as u8)
|
||||
} else {
|
||||
x.parse::<f32>().ok().map(|f| f.clamp(0.0, 255.0).round() as u8)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if let [r, g, b] = v[..] {
|
||||
return Some(over_white(r, g, b, alpha));
|
||||
}
|
||||
return None;
|
||||
}
|
||||
if let Some(inner) = s.strip_prefix("hsl(").or_else(|| s.strip_prefix("hsla(")) {
|
||||
let inner = inner.trim_end_matches(')').trim();
|
||||
let (hsl_part, alpha_part) = inner.split_once('/').map_or((inner, None), |(a, b)| (a, Some(b)));
|
||||
let parts: Vec<&str> = if hsl_part.contains(',') { hsl_part.split(',').collect() } else { hsl_part.split_whitespace().collect() };
|
||||
let alpha = match alpha_part {
|
||||
Some(a) => css_alpha(Some(a)),
|
||||
None if parts.len() >= 4 => css_alpha(Some(parts[3])),
|
||||
_ => 1.0,
|
||||
};
|
||||
if parts.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let h_str = parts[0].trim();
|
||||
let h: f64 = if let Some(deg) = h_str.strip_suffix("deg") {
|
||||
deg.trim().parse().ok()?
|
||||
} else if let Some(rad) = h_str.strip_suffix("rad") {
|
||||
rad.trim().parse::<f64>().ok()? * 180.0 / std::f64::consts::PI
|
||||
} else if let Some(turn) = h_str.strip_suffix("turn") {
|
||||
turn.trim().parse::<f64>().ok()? * 360.0
|
||||
} else {
|
||||
h_str.parse().ok()?
|
||||
};
|
||||
let parse_pct = |x: &str| -> Option<f64> {
|
||||
let x = x.trim();
|
||||
if let Some(p) = x.strip_suffix('%') {
|
||||
Some((p.trim().parse::<f64>().ok()? / 100.0).clamp(0.0, 1.0))
|
||||
} else {
|
||||
Some(x.parse::<f64>().ok()?.clamp(0.0, 1.0))
|
||||
}
|
||||
};
|
||||
let s = parse_pct(parts[1])?;
|
||||
let l = parse_pct(parts[2])?;
|
||||
let Rgb(r, g, b) = hsl_to_rgb(h, s, l);
|
||||
return Some(over_white(r, g, b, alpha));
|
||||
}
|
||||
Some(match s.as_str() {
|
||||
"black" => Rgb(0, 0, 0),
|
||||
"white" => Rgb(255, 255, 255),
|
||||
"red" => Rgb(255, 0, 0),
|
||||
"green" => Rgb(0, 128, 0),
|
||||
"lime" => Rgb(0, 255, 0),
|
||||
"blue" => Rgb(0, 0, 255),
|
||||
"yellow" => Rgb(255, 255, 0),
|
||||
"cyan" | "aqua" => Rgb(0, 255, 255),
|
||||
"magenta" | "fuchsia" => Rgb(255, 0, 255),
|
||||
"gray" | "grey" => Rgb(128, 128, 128),
|
||||
"silver" => Rgb(192, 192, 192),
|
||||
"maroon" => Rgb(128, 0, 0),
|
||||
"olive" => Rgb(128, 128, 0),
|
||||
"navy" => Rgb(0, 0, 128),
|
||||
"purple" => Rgb(128, 0, 128),
|
||||
"teal" => Rgb(0, 128, 128),
|
||||
"orange" => Rgb(255, 165, 0),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn eight_digit_hex_rejects_signs() {
|
||||
assert_eq!(parse_color("#+1+2+3+4"), None);
|
||||
// #11223344 is alpha 0x44 over white.
|
||||
assert_eq!(parse_color("#11223344"), Some(Rgb(192, 196, 201)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colors_with_alpha_composite_over_white() {
|
||||
// The secondary text colour of dark-theme sites: solid black would read as the site's
|
||||
// own emphasis, white is invisible on the page; the composite is what the eye saw.
|
||||
assert_eq!(parse_color("rgba(0, 0, 0, 0.45)"), Some(Rgb(140, 140, 140)));
|
||||
assert_eq!(parse_color("rgb(0 0 0 / 0.45)"), Some(Rgb(140, 140, 140)));
|
||||
assert_eq!(parse_color("rgba(0, 0, 0, 0)"), Some(Rgb(255, 255, 255)));
|
||||
assert_eq!(parse_color("rgba(255, 0, 0, 1)"), Some(Rgb(255, 0, 0)));
|
||||
assert_eq!(parse_color("rgba(255, 0, 0, 45%)"), Some(Rgb(255, 140, 140)));
|
||||
assert_eq!(parse_color("hsla(120, 100%, 50%, 0.5)"), Some(Rgb(128, 255, 128)));
|
||||
// A 4-digit hex's last digit is the alpha, too: #0008 is black at 8/15 over white.
|
||||
assert_eq!(parse_color("#0008"), Some(Rgb(119, 119, 119)));
|
||||
// `none` is a missing alpha, and a missing alpha is transparent, not the opaque default
|
||||
// an omitted alpha takes.
|
||||
assert_eq!(parse_color("rgb(0 0 0 / none)"), Some(Rgb(255, 255, 255)));
|
||||
assert_eq!(parse_color("rgb(0 0 0)"), Some(Rgb(0, 0, 0)));
|
||||
// An alpha that is not a finite number is no alpha: the colour stays opaque, not black.
|
||||
assert_eq!(parse_color("rgba(0, 0, 0, nan)"), Some(Rgb(0, 0, 0)));
|
||||
assert_eq!(parse_color("rgba(0, 0, 0, inf)"), Some(Rgb(0, 0, 0)));
|
||||
assert_eq!(parse_color("rgba(0, 0, 0, nan%)"), Some(Rgb(0, 0, 0)));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,187 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
// The code came from an app whose lints allow this.
|
||||
#![allow(clippy::type_complexity)]
|
||||
//! A small "flow" model every text format maps to and from: paragraphs with a kind (heading,
|
||||
//! quote, code…), list membership, alignment and formatted inline spans; tables of cells holding
|
||||
//! blocks. [`to_doc`] and [`from_doc`] convert between it and the full [`Document`].
|
||||
|
||||
#[cfg(doctest)]
|
||||
#[doc = include_str!("../README.md")]
|
||||
struct ReadmeExamples;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_wordmodel::numbering::{AbstractNum, Level, ListKind, Num, levels_for};
|
||||
use craft_wordmodel::para::{InlineObject, OBJ, PAGE_BREAK, RubyAlign, Run, ruby_raise};
|
||||
use craft_wordmodel::props::{
|
||||
Border, BorderStyle, Borders, CellProps, CharProps, NumRef, ParaProps, PropChange, RowProps, TextColor, Underline, VAlign, VMerge, VertAlign,
|
||||
};
|
||||
use craft_wordmodel::section::NumFormat;
|
||||
use craft_wordmodel::styles::{Style, StyleKind};
|
||||
use craft_wordmodel::{Align, Block, Blocks, Document, Paragraph, Revision, RevisionKind, Rgb, Table, para_block};
|
||||
|
||||
mod color;
|
||||
mod from_doc;
|
||||
mod media;
|
||||
pub mod odf_object;
|
||||
mod to_doc;
|
||||
mod types;
|
||||
|
||||
pub use color::parse_color;
|
||||
pub use from_doc::{
|
||||
ListCounter, flow_paras, from_doc, from_doc_tracked, from_doc_with, is_custom_char_style, is_custom_style, is_mono, para_kind, part_flow,
|
||||
tabs_from_indent,
|
||||
};
|
||||
pub use media::{base64_decode, base64_encode, data_uri, image_px, make_img, mime_of, percent_decode, sized_img, sniff_image};
|
||||
pub use to_doc::{char_props, part_blocks, to_doc, to_doc_in};
|
||||
pub use types::{
|
||||
Affix, BoxLook, CODE_STYLE, Cell, CellPic, Chain, Chart, Conn, FBlock, FComment, FTable, Flow, FlowOpts, Fmt, FmtChange, Group, Img, Inline,
|
||||
Kind, Label, ListInfo, MAX_DEPTH, MAX_FONT_NAME, MAX_GROUP_DEPTH, MAX_LINK, MONO_FONT, Member, Meta, OdfObject, Para, RUBY_BASE_SIZE, RevMark,
|
||||
Spacing, TableExtra, clean_text, equations_as_text, flatten_tables, kept_font, kept_link, ranged_anchors, ruby_as_text, ruby_object,
|
||||
shows_nothing,
|
||||
};
|
||||
pub use types::{Dropped, MAX_TABLE_CELLS, begin_import, dropped};
|
||||
|
||||
thread_local! {
|
||||
/// The kinds of [`Dropped`] already reported for the document being imported on this thread.
|
||||
/// Per thread because a document is parsed and built on one, and the parsers drop things
|
||||
/// from places that have no log of their own to hand.
|
||||
static REPORTED: std::cell::Cell<u8> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// ASK and SET show nothing (layout shows no result for them): no text export writes the
|
||||
/// result they hold, from the document or from a flow with fields; ODT keeps it.
|
||||
#[test]
|
||||
fn ask_and_set_fields_export_no_text() {
|
||||
let mut doc = craft_wordmodel::Document::from_text("Before after");
|
||||
if let Ok(p) = doc.para_mut(craft_wordmodel::StoryRef::Body, &craft_wordmodel::Path::top(0)) {
|
||||
for instr in ["SET name other", "ASK other \"Prompt\""] {
|
||||
let field = craft_wordmodel::para::InlineObject::Field {
|
||||
instr: instr.into(),
|
||||
result: "SECRETVALUE".into(),
|
||||
locked: false,
|
||||
dirty: false,
|
||||
code: false,
|
||||
form: String::new(),
|
||||
};
|
||||
p.insert_object(7, field, &Default::default()).unwrap();
|
||||
}
|
||||
}
|
||||
let flow = from_doc_with(&doc, FlowOpts { fields: true, ..Default::default() });
|
||||
// The flow keeps the result for ODT.
|
||||
let kept = flow
|
||||
.blocks
|
||||
.iter()
|
||||
.any(|b| matches!(b, FBlock::Para(p) if p.inlines.iter().any(|i| matches!(i, Inline::Field { result, .. } if result == "SECRETVALUE"))));
|
||||
assert!(kept);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percent_decode_requires_hex_digits() {
|
||||
assert_eq!(percent_decode("%+A"), b"%+A".to_vec());
|
||||
assert_eq!(percent_decode("%-A"), b"%-A".to_vec());
|
||||
assert_eq!(percent_decode("%G1"), b"%G1".to_vec());
|
||||
assert_eq!(percent_decode("%41"), b"A".to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_covered_is_bounded_on_hostile_rowspan() {
|
||||
// Many rows each carrying the maximum rowspan would be O(rows × rowspan) covered cells;
|
||||
// the work cap must keep insertion bounded (and quick) instead of exhausting memory.
|
||||
let mut t = FTable {
|
||||
rows: (0..20_000).map(|_| vec![Cell { rowspan: 1000, ..Default::default() }]).collect(),
|
||||
widths: Vec::new(),
|
||||
borderless: false,
|
||||
extra: None,
|
||||
kept: None,
|
||||
wrap: None,
|
||||
};
|
||||
t.insert_covered();
|
||||
assert!(t.rows.iter().all(|r| r.len() <= 64), "a row grew past the column cap");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_round_trip() {
|
||||
for data in [&b""[..], b"f", b"fo", b"foo", b"foob", b"fooba", b"foobar", &[0, 255, 128, 7]] {
|
||||
let e = base64_encode(data);
|
||||
assert_eq!(base64_decode(&e).unwrap(), data, "{e}");
|
||||
}
|
||||
assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
|
||||
assert!(base64_decode("@@").is_none());
|
||||
assert_eq!(data_uri("data:text/plain,a%20b").unwrap(), b"a b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colors() {
|
||||
assert_eq!(parse_color("#f00"), Some(Rgb(255, 0, 0)));
|
||||
assert_eq!(parse_color("#f00f"), Some(Rgb(255, 0, 0)));
|
||||
assert_eq!(parse_color("#0080FF"), Some(Rgb(0, 128, 255)));
|
||||
assert_eq!(parse_color("#0080FFff"), Some(Rgb(0, 128, 255)));
|
||||
// A colour with alpha is composited over the white page it lands on (#0080ff at 50%).
|
||||
assert_eq!(parse_color("#0080ff80"), Some(Rgb(127, 191, 255)));
|
||||
assert_eq!(parse_color("rgb(1, 2, 3)"), Some(Rgb(1, 2, 3)));
|
||||
assert_eq!(parse_color("rgb(1 2 3)"), Some(Rgb(1, 2, 3)));
|
||||
assert_eq!(parse_color("rgb(1 2 3 / 50%)"), Some(Rgb(128, 129, 129)));
|
||||
assert_eq!(parse_color("hsl(0, 100%, 50%)"), Some(Rgb(255, 0, 0)));
|
||||
assert_eq!(parse_color("hsl(120deg 100% 50%)"), Some(Rgb(0, 255, 0)));
|
||||
assert_eq!(parse_color("hsla(240, 100%, 50%, 0.8)"), Some(Rgb(51, 51, 255)));
|
||||
assert_eq!(parse_color("navy"), Some(Rgb(0, 0, 128)));
|
||||
assert_eq!(parse_color("#zz"), None);
|
||||
assert_eq!(parse_color("rgb(1)"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_labels() {
|
||||
let mut c = ListCounter::default();
|
||||
let o = |level| ListInfo { ordered: true, level, start: 1, ..Default::default() };
|
||||
assert_eq!(c.label(o(0)), "1.");
|
||||
assert_eq!(c.label(o(1)), "a.");
|
||||
assert_eq!(c.label(o(1)), "b.");
|
||||
assert_eq!(c.label(o(0)), "2.");
|
||||
assert_eq!(c.label(ListInfo { ordered: false, level: 0, start: 1, ..Default::default() }), "•");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn covered_cells_inserted() {
|
||||
let mut t = FTable {
|
||||
rows: vec![vec![Cell { rowspan: 2, ..Default::default() }, Cell::default()], vec![Cell::default()]],
|
||||
widths: vec![],
|
||||
borderless: false,
|
||||
extra: None,
|
||||
kept: None,
|
||||
wrap: None,
|
||||
};
|
||||
t.insert_covered();
|
||||
assert_eq!(t.rows[1].len(), 2);
|
||||
assert!(t.rows[1][0].covered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_drops_a_long_run_of_blank_spans_in_linear_time() {
|
||||
// Separately formatted line breaks don't merge; each used to be removed with
|
||||
// `Vec::remove(0)`, shifting all the rest (quadratic).
|
||||
let mut p = Para::default();
|
||||
let (b, i) = (Fmt { bold: true, ..Default::default() }, Fmt { italic: true, ..Default::default() });
|
||||
for _ in 0..200_000 {
|
||||
p.push_text("\n", &b);
|
||||
p.push_text(" ", &i);
|
||||
}
|
||||
p.push_text(" x ", &Fmt::default());
|
||||
let t = std::time::Instant::now();
|
||||
p.trim();
|
||||
// A fraction of a second; `Vec::remove(0)` per span ran past 6 minutes.
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed());
|
||||
assert_eq!(p.inlines, vec![Inline::Text("x".into(), Fmt::default())]);
|
||||
// The first span left keeps its formatting and loses only its leading blanks.
|
||||
let mut p = Para::default();
|
||||
p.push_text(" \t", &b);
|
||||
p.push_text(" a ", &i);
|
||||
p.push_text("b ", &b);
|
||||
p.trim();
|
||||
assert_eq!(p.inlines, vec![Inline::Text("a ".into(), i), Inline::Text("b".into(), b)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
use super::Arc;
|
||||
use super::types::Img;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
|
||||
/// Image extension from bytes, if it's a format we can display.
|
||||
pub fn sniff_image(data: &[u8]) -> Option<&'static str> {
|
||||
match image::guess_format(data).ok()? {
|
||||
image::ImageFormat::Png => Some("png"),
|
||||
image::ImageFormat::Jpeg => Some("jpeg"),
|
||||
image::ImageFormat::Gif => Some("gif"),
|
||||
image::ImageFormat::WebP => Some("webp"),
|
||||
image::ImageFormat::Bmp => Some("bmp"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixel size of encoded image bytes.
|
||||
pub fn image_px(data: &[u8]) -> Option<(u32, u32)> {
|
||||
image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format().ok()?.into_dimensions().ok()
|
||||
}
|
||||
|
||||
/// A picture from bytes with an optional display size (points); the natural size (96 dpi) is
|
||||
/// used otherwise, shrunk to fit a 6.5" column.
|
||||
pub fn make_img(data: impl Into<Arc<Vec<u8>>>, w: Option<f32>, h: Option<f32>, alt: &str) -> Option<Img> {
|
||||
let data = data.into();
|
||||
let ext = sniff_image(&data)?;
|
||||
let natural = image_px(&data).map(|(a, b)| (a as f32 * 0.75, b as f32 * 0.75));
|
||||
Some(sized_img(data, ext, natural, w, h, alt))
|
||||
}
|
||||
|
||||
/// A picture of a known format `ext` whose natural size (points) the caller knows, if at all: a
|
||||
/// metafile, which `image` can't measure. Sized like [`make_img`].
|
||||
pub fn sized_img(data: Arc<Vec<u8>>, ext: &str, natural: Option<(f32, f32)>, w: Option<f32>, h: Option<f32>, alt: &str) -> Img {
|
||||
let (pw, ph) = natural.filter(|(a, b)| a.is_finite() && b.is_finite()).unwrap_or((72.0, 72.0));
|
||||
let (pw, ph) = (pw.max(1.0), ph.max(1.0));
|
||||
let (mut w, mut h) = match (w.filter(|v| v.is_finite() && *v > 0.0), h.filter(|v| v.is_finite() && *v > 0.0)) {
|
||||
(Some(w), Some(h)) => (w, h),
|
||||
(Some(w), None) => (w, w * ph / pw),
|
||||
(None, Some(h)) => (h * pw / ph, h),
|
||||
(None, None) => (pw, ph),
|
||||
};
|
||||
if w > 468.0 {
|
||||
h *= 468.0 / w;
|
||||
w = 468.0;
|
||||
}
|
||||
Img {
|
||||
data,
|
||||
ext: ext.to_string(),
|
||||
w: w.clamp(1.0, 1584.0),
|
||||
h: h.clamp(1.0, 1584.0),
|
||||
alt: alt.to_string(),
|
||||
float: None,
|
||||
object: None,
|
||||
crop: [0.0; 4],
|
||||
}
|
||||
}
|
||||
|
||||
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
pub fn base64_encode(data: &[u8]) -> String {
|
||||
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
|
||||
for ch in data.chunks(3) {
|
||||
let b = [ch.first().copied().unwrap_or(0), ch.get(1).copied().unwrap_or(0), ch.get(2).copied().unwrap_or(0)];
|
||||
let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
|
||||
let idx = [(n >> 18) & 63, (n >> 12) & 63, (n >> 6) & 63, n & 63];
|
||||
for (i, v) in idx.iter().enumerate() {
|
||||
if i <= ch.len() {
|
||||
out.push(B64.get(*v as usize).copied().unwrap_or(b'A') as char);
|
||||
} else {
|
||||
out.push('=');
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Lenient base64 (standard or URL-safe alphabet; whitespace and padding ignored).
|
||||
pub fn base64_decode(s: &str) -> Option<Vec<u8>> {
|
||||
let mut out = Vec::with_capacity(s.len() / 4 * 3);
|
||||
let mut acc = 0u32;
|
||||
let mut bits = 0u32;
|
||||
for c in s.bytes() {
|
||||
let v = match c {
|
||||
b'A'..=b'Z' => c - b'A',
|
||||
b'a'..=b'z' => c - b'a' + 26,
|
||||
b'0'..=b'9' => c - b'0' + 52,
|
||||
b'+' | b'-' => 62,
|
||||
b'/' | b'_' => 63,
|
||||
b'=' => break,
|
||||
c if c.is_ascii_whitespace() => continue,
|
||||
_ => return None,
|
||||
};
|
||||
acc = (acc << 6) | u32::from(v);
|
||||
bits += 6;
|
||||
if bits >= 8 {
|
||||
bits -= 8;
|
||||
out.push((acc >> bits) as u8);
|
||||
acc &= (1 << bits) - 1;
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Decode a `data:` URI (base64 or percent-encoded).
|
||||
pub fn data_uri(uri: &str) -> Option<Vec<u8>> {
|
||||
let rest = uri.trim().strip_prefix("data:")?;
|
||||
let (head, data) = rest.split_once(',')?;
|
||||
if head.ends_with(";base64") { base64_decode(data) } else { Some(percent_decode(data)) }
|
||||
}
|
||||
|
||||
pub fn percent_decode(s: &str) -> Vec<u8> {
|
||||
let b = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(b.len());
|
||||
let mut i = 0;
|
||||
while let Some(&c) = b.get(i) {
|
||||
if c == b'%'
|
||||
&& let Some(h) = b.get(i + 1..i + 3)
|
||||
&& h.iter().all(u8::is_ascii_hexdigit)
|
||||
&& let Some(hex) = std::str::from_utf8(h).ok()
|
||||
&& let Ok(v) = u8::from_str_radix(hex, 16)
|
||||
{
|
||||
out.push(v);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn mime_of(ext: &str) -> &'static str {
|
||||
match ext {
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"gif" => "image/gif",
|
||||
"webp" => "image/webp",
|
||||
"bmp" => "image/bmp",
|
||||
"svg" => "image/svg+xml",
|
||||
"ico" => "image/x-icon",
|
||||
"wmf" => "image/x-wmf",
|
||||
"emf" => "image/x-emf",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! ODF embedded objects ([`OdfObject`]) in a document: their files in
|
||||
//! [`Document::passthrough`] under `odf-object/<n>/`, linked from the picture showing them by an
|
||||
//! [`Embedded`] whose markup only names the object's media type, and taken back out for the ODT
|
||||
//! writer. Other formats see the picture alone.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_wordmodel::Document;
|
||||
use craft_wordmodel::graphic::{Embedded, EmbeddedRef};
|
||||
|
||||
use super::types::OdfObject;
|
||||
|
||||
/// Relationship kind of an object's file, and of its replacement picture.
|
||||
const FILE: &str = "file";
|
||||
const REPLACEMENT: &str = "replacement";
|
||||
/// Most objects a document keeps, and most files in one.
|
||||
pub const MAX_OBJECTS: usize = 1000;
|
||||
pub const MAX_FILES: usize = 256;
|
||||
|
||||
fn esc(s: &str) -> String {
|
||||
s.replace('&', "&").replace('"', """).replace('<', "<").replace('>', ">")
|
||||
}
|
||||
|
||||
fn unesc(s: &str) -> String {
|
||||
s.replace(""", "\"").replace("<", "<").replace(">", ">").replace("&", "&")
|
||||
}
|
||||
|
||||
/// Keep `obj` in `doc`: its files as passthrough parts, and the link to them. `None` when the
|
||||
/// document already holds as many objects as it keeps.
|
||||
pub fn store(doc: &mut Document, obj: &OdfObject) -> Option<Arc<Embedded>> {
|
||||
let taken = |n: usize| {
|
||||
let prefix = format!("odf-object/{n}/");
|
||||
doc.passthrough.range(prefix.clone()..).next().is_some_and(|(k, _)| k.starts_with(&prefix))
|
||||
};
|
||||
let n = (0..MAX_OBJECTS).find(|&n| !taken(n))?;
|
||||
let mut refs = Vec::new();
|
||||
for (k, (path, bytes)) in obj.files.iter().take(MAX_FILES).enumerate() {
|
||||
let target = format!("odf-object/{n}/{k}");
|
||||
doc.passthrough.insert(target.clone(), bytes.clone());
|
||||
refs.push(EmbeddedRef { id: path.clone(), kind: FILE.into(), target, external: false });
|
||||
}
|
||||
if let Some(r) = &obj.replacement {
|
||||
let target = format!("odf-object/{n}/replacement");
|
||||
doc.passthrough.insert(target.clone(), r.clone());
|
||||
refs.push(EmbeddedRef { id: String::new(), kind: REPLACEMENT.into(), target, external: false });
|
||||
}
|
||||
let xml = format!("{} mime=\"{}\" ole=\"{}\"/>", Embedded::ODF_OBJECT, esc(&obj.mime), obj.ole);
|
||||
Some(Arc::new(Embedded { xml, refs }))
|
||||
}
|
||||
|
||||
/// The object `e` links to in `doc`, when it is an ODF object whose files are all there.
|
||||
pub fn load(doc: &Document, e: &Embedded) -> Option<Arc<OdfObject>> {
|
||||
if !e.is_odf_object() {
|
||||
return None;
|
||||
}
|
||||
let attr = |k: &str| {
|
||||
let pat = format!(" {k}=\"");
|
||||
let from = e.xml.find(&pat)? + pat.len();
|
||||
let len = e.xml.get(from..)?.find('"')?;
|
||||
e.xml.get(from..from + len).map(unesc)
|
||||
};
|
||||
let mut obj = OdfObject { mime: attr("mime").unwrap_or_default(), ole: attr("ole").as_deref() == Some("true"), ..Default::default() };
|
||||
for r in e.refs.iter().take(MAX_FILES + 1) {
|
||||
let bytes = doc.passthrough.get(&r.target)?.clone();
|
||||
match r.kind.as_str() {
|
||||
FILE => obj.files.push((r.id.clone(), bytes)),
|
||||
REPLACEMENT => obj.replacement = Some(bytes),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(!obj.files.is_empty()).then(|| Arc::new(obj))
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,101 @@
|
||||
//! A flow made into a document and read back keeps what the text formats express: kinds,
|
||||
//! lists, alignment, character formatting, links, tables, pictures and the metadata.
|
||||
|
||||
use craft_flow::{Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, from_doc, make_img, to_doc};
|
||||
use craft_wordmodel::Align;
|
||||
|
||||
/// A 1×1 PNG.
|
||||
const PNG: &[u8] = &[
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0x0D, 0x49, 0x48, 0x44, 0x52, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 0x1F, 0x15, 0xC4,
|
||||
0x89, 0, 0, 0, 0x0D, 0x49, 0x44, 0x41, 0x54, 0x78, 0xDA, 0x63, 0xFC, 0xCF, 0xC0, 0x50, 0x0F, 0, 0x04, 0x85, 0x01, 0x80, 0x84, 0xA9, 0x8C, 0x21,
|
||||
0, 0, 0, 0, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
|
||||
];
|
||||
|
||||
fn para(kind: Kind, inlines: Vec<Inline>) -> Para {
|
||||
Para { kind, inlines, ..Default::default() }
|
||||
}
|
||||
|
||||
fn text(s: &str, f: Fmt) -> Inline {
|
||||
Inline::Text(s.into(), f)
|
||||
}
|
||||
|
||||
fn sample() -> Flow {
|
||||
let bold = Fmt { bold: true, ..Default::default() };
|
||||
let link = Fmt { link: Some("https://example.com/x".into()), ..Default::default() };
|
||||
let mut centred =
|
||||
para(Kind::Normal, vec![text("Centred ", Fmt::default()), text("bold", bold.clone()), text(" and ", Fmt::default()), text("a link", link)]);
|
||||
centred.align = Some(Align::Center);
|
||||
let item = |s: &str, ordered: bool, level: u8| Para {
|
||||
list: Some(ListInfo { ordered, level, ..Default::default() }),
|
||||
..para(Kind::Normal, vec![text(s, Fmt::default())])
|
||||
};
|
||||
let cell = |s: &str, header: bool| Cell {
|
||||
blocks: vec![FBlock::Para(para(Kind::Normal, vec![text(s, Fmt::default())]))],
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
header,
|
||||
..Default::default()
|
||||
};
|
||||
let table = FTable { rows: vec![vec![cell("A", true), cell("B", true)], vec![cell("1", false), cell("2", false)]], ..Default::default() };
|
||||
let img = make_img(PNG.to_vec(), Some(36.0), None, "tiny").map(Inline::Image).into_iter().collect();
|
||||
Flow {
|
||||
blocks: vec![
|
||||
FBlock::Para(para(Kind::Heading(1), vec![text("Main heading", Fmt::default())])),
|
||||
FBlock::Para(centred),
|
||||
FBlock::Para(item("first", false, 0)),
|
||||
FBlock::Para(item("nested", true, 1)),
|
||||
FBlock::Para(para(Kind::Quote, vec![text("Quoted.", Fmt::default())])),
|
||||
FBlock::Para(para(Kind::Code, vec![text("let x = 1;", Fmt { code: true, ..Default::default() })])),
|
||||
FBlock::Table(table),
|
||||
FBlock::Para(para(Kind::Normal, img)),
|
||||
],
|
||||
meta: Meta { title: "Sample".into(), author: "Ada".into(), ..Default::default() },
|
||||
}
|
||||
}
|
||||
|
||||
fn paras(blocks: &[FBlock]) -> Vec<&Para> {
|
||||
blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flow_survives_a_document() {
|
||||
let flow = sample();
|
||||
let doc = to_doc(&flow);
|
||||
assert_eq!((doc.core.title.as_str(), doc.core.creator.as_str()), ("Sample", "Ada"));
|
||||
assert_eq!(doc.media.len(), 1, "the picture is in the document");
|
||||
let back = from_doc(&doc);
|
||||
let (want, got) = (paras(&flow.blocks), paras(&back.blocks));
|
||||
assert_eq!(got.len(), want.len());
|
||||
for (w, g) in want.iter().zip(&got) {
|
||||
assert_eq!((g.kind, g.list.map(|l| (l.ordered, l.level)), g.align), (w.kind, w.list.map(|l| (l.ordered, l.level)), w.align), "{}", w.text());
|
||||
assert_eq!(g.text(), w.text());
|
||||
}
|
||||
let fmt_of = |p: &Para, s: &str| {
|
||||
p.inlines.iter().find_map(|i| {
|
||||
if let Inline::Text(t, f) = i
|
||||
&& t == s
|
||||
{
|
||||
Some(f.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
};
|
||||
assert!(fmt_of(got[1], "bold").is_some_and(|f| f.bold));
|
||||
assert_eq!(fmt_of(got[1], "a link").and_then(|f| f.link).as_deref(), Some("https://example.com/x"));
|
||||
assert!(matches!(got[6].inlines.as_slice(), [Inline::Image(i)] if i.alt == "tiny" && (i.w - 36.0).abs() < 0.01));
|
||||
let Some(FBlock::Table(t)) = back.blocks.iter().find(|b| matches!(b, FBlock::Table(_))) else { panic!("no table") };
|
||||
assert_eq!(t.rows.len(), 2);
|
||||
assert_eq!(t.rows[1].iter().map(Cell::text).collect::<Vec<_>>(), ["1", "2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pictures_are_sniffed_and_sized_from_their_headers() {
|
||||
assert_eq!(craft_flow::sniff_image(PNG), Some("png"));
|
||||
assert_eq!(craft_flow::image_px(PNG), Some((1, 1)));
|
||||
assert_eq!(craft_flow::sniff_image(b"not a picture"), None);
|
||||
assert!(make_img(b"not a picture".to_vec(), None, None, "").is_none());
|
||||
// A picture wider than the column is shrunk to it, keeping its proportions.
|
||||
let wide = make_img(PNG.to_vec(), Some(1000.0), Some(500.0), "").unwrap();
|
||||
assert_eq!((wide.w, wide.h), (468.0, 234.0));
|
||||
}
|
||||
Reference in new issue
Block a user