mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 22:42:46 +00:00
* 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.
188 lines
8.2 KiB
Rust
188 lines
8.2 KiB
Rust
#![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)]);
|
||
}
|
||
}
|