Compare commits

...
Author SHA1 Message Date
dependabot[bot] eeb3a2d31f chore(deps): bump the hayro group across 1 directory with 3 updates
Bumps the hayro group with 3 updates in the / directory: [hayro-syntax](https://github.com/LaurenzV/hayro), [hayro-interpret](https://github.com/LaurenzV/hayro) and [hayro-cmap](https://github.com/LaurenzV/hayro).


Updates `hayro-syntax` from 0.7.2 to 0.9.0
- [Release notes](https://github.com/LaurenzV/hayro/releases)
- [Commits](https://github.com/LaurenzV/hayro/commits/hayro-syntax-v0.9.0)

Updates `hayro-interpret` from 0.7.0 to 0.8.0
- [Release notes](https://github.com/LaurenzV/hayro/releases)
- [Commits](https://github.com/LaurenzV/hayro/compare/hayro-interpret-v0.7.0...hayro-interpret-v0.8.0)

Updates `hayro-cmap` from 0.1.0 to 0.1.1
- [Release notes](https://github.com/LaurenzV/hayro/releases)
- [Commits](https://github.com/LaurenzV/hayro/compare/hayro-cmap-v0.1.0...hayro-cmap-v0.1.1)

---
updated-dependencies:
- dependency-name: hayro-cmap
  dependency-version: 0.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: hayro
- dependency-name: hayro-interpret
  dependency-version: 0.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: hayro
- dependency-name: hayro-syntax
  dependency-version: 0.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: hayro
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-10 11:07:34 +00:00
2922fe566b RTF: style-sheet names take Unicode escapes; a malformed \' escape leaks no bytes (#6165)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: galagersharp-code <319712501+galagersharp-code@users.noreply.github.com>
2026-11-10 01:24:23 +01:00
43f94a5dca Paste: composite CSS colours with alpha over the page instead of dropping it (#6164)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: galagersharp-code <319712501+galagersharp-code@users.noreply.github.com>
2026-11-10 01:09:36 +01:00
7 changed files with 233 additions and 43 deletions
Generated
+65 -26
View File
@@ -2337,12 +2337,6 @@ dependencies = [
"foldhash 0.2.0",
]
[[package]]
name = "hayro-ccitt"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
[[package]]
name = "hayro-ccitt"
version = "0.4.0"
@@ -2350,10 +2344,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2866f0d976e76497480b853d5e258dab6e4b89ecfe991124e08f15dfa8fd03ad"
[[package]]
name = "hayro-cmap"
version = "0.1.0"
name = "hayro-ccitt"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6"
checksum = "adbed896e99325bc312778bb9c9c289d795d51c12775c44dd5e4352f679c7e46"
[[package]]
name = "hayro-cmap"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a14afde3344be69225fec4f6f44f6a219a3a492f9d8301eb25ef608fe4b7014c"
dependencies = [
"brotli",
"hayro-postscript",
@@ -2361,19 +2361,19 @@ dependencies = [
[[package]]
name = "hayro-interpret"
version = "0.7.0"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2613d0406898995042d0794c4245b2f2fba1246490c8ac593769bba6551129d"
checksum = "ac9df074ff163a63864cec9b0bf640ccbff8abf4e44481df7369ce0ac172ffbe"
dependencies = [
"bitflags 2.13.2",
"hayro-cmap",
"hayro-syntax",
"hayro-syntax 0.8.0",
"kurbo",
"moxcms",
"phf 0.13.1",
"rustc-hash 2.1.3",
"siphasher",
"skrifa 0.42.1",
"skrifa 0.46.2",
"smallvec",
"yoke",
]
@@ -2389,36 +2389,65 @@ dependencies = [
]
[[package]]
name = "hayro-jpeg2000"
version = "0.3.5"
name = "hayro-jbig2"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
checksum = "eb5c7f3a4eb551c2bb7fa16e268f728d4bc2a5c9b090554b8dc0ee2503fb5840"
dependencies = [
"fearless_simd 0.4.1",
"hayro-ccitt 0.5.0",
]
[[package]]
name = "hayro-postscript"
version = "0.1.0"
name = "hayro-jpeg2000"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa"
checksum = "122c5f834c37a2260b9c851bb8be685cf539b2a8b94b704a931ef7d2525d5835"
dependencies = [
"fearless_simd 1.1.0",
]
[[package]]
name = "hayro-jpeg2000"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e5e5f2b3de970707fc22fd956d8f44211602939d5e4588603afa919cb7b3740"
[[package]]
name = "hayro-postscript"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc2d80da85f31c3fdc0366431f44cfd645a9da1e242ed1f4a61f213d9765660f"
[[package]]
name = "hayro-syntax"
version = "0.7.2"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27"
checksum = "6cdc818d8d265dcb42081d488f7599df4f73fb79e9d347ecedcd3c23809d2026"
dependencies = [
"flate2",
"hayro-ccitt 0.3.0",
"hayro-jbig2",
"hayro-jpeg2000",
"hayro-ccitt 0.4.0",
"hayro-jbig2 0.3.1",
"hayro-jpeg2000 0.4.1",
"memchr",
"rustc-hash 2.1.3",
"smallvec",
"zune-jpeg",
]
[[package]]
name = "hayro-syntax"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ced8279487b029813f6cc05399099fa34df6fa23d8f7b0d41d972c49d5b30d"
dependencies = [
"hayro-ccitt 0.5.0",
"hayro-jbig2 0.4.0",
"hayro-jpeg2000 0.5.0",
"rustc-hash 2.1.3",
"smallvec",
"zune-jpeg",
]
[[package]]
name = "heck"
version = "0.5.0"
@@ -5114,6 +5143,16 @@ dependencies = [
"read-fonts 0.41.0",
]
[[package]]
name = "skrifa"
version = "0.46.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4febebda507fb889c545a37a135517769d1391941013561292897961d1887d6"
dependencies = [
"bytemuck",
"read-fonts 0.43.3",
]
[[package]]
name = "skrifa"
version = "0.47.0"
@@ -7048,7 +7087,7 @@ dependencies = [
"craft-pdf",
"hayro-cmap",
"hayro-interpret",
"hayro-syntax",
"hayro-syntax 0.9.0",
"image",
"js-sys",
"kurbo",
+5 -3
View File
@@ -123,13 +123,15 @@ mod tests {
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("#0080ff80"), 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(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(0, 0, 255)));
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);
+73 -9
View File
@@ -34,24 +34,59 @@ fn hsl_to_rgb(h: f64, s: f64, l: f64) -> Rgb {
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);
return Some(Rgb(d(0)?, d(1)?, d(2)?));
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(Rgb(p(0)?, p(2)?, p(4)?));
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 before_slash = inner.split('/').next().unwrap_or(inner);
let parts: Vec<&str> = if before_slash.contains(',') { before_slash.split(',').collect() } else { before_slash.split_whitespace().collect() };
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)
@@ -65,14 +100,19 @@ pub fn parse_color(s: &str) -> Option<Rgb> {
})
.collect();
if let [r, g, b] = v[..] {
return Some(Rgb(r, g, b));
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 before_slash = inner.split('/').next().unwrap_or(inner);
let parts: Vec<&str> = if before_slash.contains(',') { before_slash.split(',').collect() } else { before_slash.split_whitespace().collect() };
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;
}
@@ -96,7 +136,8 @@ pub fn parse_color(s: &str) -> Option<Rgb> {
};
let s = parse_pct(parts[1])?;
let l = parse_pct(parts[2])?;
return Some(hsl_to_rgb(h, s, l));
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),
@@ -127,6 +168,29 @@ mod tests {
#[test]
fn eight_digit_hex_rejects_signs() {
assert_eq!(parse_color("#+1+2+3+4"), None);
assert_eq!(parse_color("#11223344"), Some(Rgb(0x11, 0x22, 0x33)));
// #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)));
}
}
+18 -2
View File
@@ -135,7 +135,13 @@ fn parse_full(bytes: &[u8], marks: bool) -> Result<Parsed, String> {
}
match n {
b'\'' => {
let h = bytes.get(i + 2..i + 4).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok());
// `from_str_radix` takes a leading `+`: a pair that isn't both hex digits
// is not an escape at all.
let h = bytes
.get(i + 2..i + 4)
.and_then(|h| std::str::from_utf8(h).ok())
.filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit()))
.and_then(|h| u8::from_str_radix(h, 16).ok());
match h {
Some(v) => {
if r.st.dest == Dest::Pict {
@@ -150,7 +156,17 @@ fn parse_full(bytes: &[u8], marks: bool) -> Result<Parsed, String> {
}
i += 4;
}
None => i += 2,
// Not two hex digits: the escape is malformed. Consume the escape and
// any hex digits of a truncated pair, and leave every other byte,
// `}` or a control word's `\`, to be read as the structure it is.
None => {
i += 2;
let mut n = 0;
while n < 2 && bytes.get(i).is_some_and(u8::is_ascii_hexdigit) {
i += 1;
n += 1;
}
}
}
}
b'*' => {
+67
View File
@@ -37,6 +37,73 @@ fn basic_reader() {
assert!(matches!(&l.inlines[0], Inline::Text(_, f) if f.link.as_deref() == Some("http://a.b")));
}
#[test]
fn stylesheet_names_take_unicode_escapes() {
// \u116?=t \u105?=i \u116?=t \u108?=l \u101?=e — how Word writes a non-Latin style name.
let rtf = br"{\rtf1\ansi{\stylesheet{\s0 Normal;}{\s2 \u116?\u105?\u116?\u108?\u101?;}}\pard\s2 T\par}";
let f = parse(rtf).unwrap();
assert_eq!(texts(&f)[0], "Title:T");
}
#[test]
fn stylesheet_names_take_negative_escapes() {
// 't' as a signed 16-bit escape: 116 - 65536 = -65420.
let rtf = br"{\rtf1\ansi{\stylesheet{\s0 Normal;}{\s2 \u-65420?\u105?\u116?\u108?\u101?;}}\pard\s2 T\par}";
let f = parse(rtf).unwrap();
assert_eq!(texts(&f)[0], "Title:T");
}
#[test]
fn stylesheet_names_honour_the_uc_fallback_count() {
// \uc2: two fallback bytes follow each \uN? — here a second `?`.
let rtf = br"{\rtf1\ansi{\stylesheet{\s0 Normal;}{\s2 \uc2\u116??\u105??\u116??\u108??\u101??;}}\pard\s2 T\par}";
let f = parse(rtf).unwrap();
assert_eq!(texts(&f)[0], "Title:T");
}
#[test]
fn a_malformed_hex_escape_leaves_no_stray_text() {
// A `\'` that is not followed by two hex digits is malformed: the escape itself goes, any
// hex digits of a truncated pair go with it, and everything else — non-hex bytes, `}` —
// is read as what it is. `\'gZ` keeps "gZ" as text; `\'4}` keeps the closing brace.
let rtf = br"{\rtf1\ansi \'gZok\par}";
let f = parse(rtf).unwrap();
assert_eq!(texts(&f)[0], "Normal:gZok");
// The bytes after a truncated pair must not swallow the group's closing brace or the next
// control word: the group and its destination state end where they should. Paragraph text
// alone would not show that: `survivor` reads the same inside the bold group, so the run's
// formatting is checked too.
let rtf = br"{\rtf1\ansi{\b \'4}survivor\par}";
let f = parse(rtf).unwrap();
assert!(texts(&f).iter().any(|t| t.contains("survivor")), "{:?}", texts(&f));
let survivor_bold = f.blocks.iter().find_map(|b| match b {
FBlock::Para(p) => p.inlines.iter().find_map(|i| match i {
Inline::Text(t, fmt) if t.contains("survivor") => Some(fmt.bold),
_ => None,
}),
FBlock::Table(_) => None,
});
assert_eq!(survivor_bold, Some(false), "survivor must not be inside the bold group: {:?}", f.blocks);
// The truncated pair's hex digit goes with the escape: no stray "4" is left in the text.
assert!(!texts(&f).iter().any(|t| t.contains('4')), "{:?}", texts(&f));
// A leading `+` is not a hex digit: `\'+4` is no escape: it stays text, not the byte 0x04.
let rtf = br"{\rtf1\ansi \'+4z\par}";
let f = parse(rtf).unwrap();
assert_eq!(texts(&f)[0], "Normal:+4z");
let rtf = br"{\rtf1\ansi{\'}}after\par}";
let f = parse(rtf).unwrap();
assert!(texts(&f).iter().any(|t| t.contains("after")), "{:?}", texts(&f));
// A hex digit that starts a valid pair with the byte after it is part of the escape:
// \'4} reads '4' as the escape's byte and leaves `}` to close the group.
let rtf = br"{\rtf1\ansi\'4}x\par}";
let f = parse(rtf).unwrap();
assert!(texts(&f).iter().any(|t| t.contains('x') && !t.contains('}')), "{:?}", texts(&f));
}
#[test]
fn unicode_fallback_uses_the_declared_count() {
for (rtf, expected) in [
+3 -1
View File
@@ -403,7 +403,8 @@ impl Reader {
}
true
}
Dest::StyleSheet => {
// Style names take Unicode escapes (with fallback skipping) like the body.
Dest::StyleSheet if !matches!(word, "u" | "uc") => {
let def = &mut self.style_def_cur;
match word {
"s" | "cs" | "ds" | "ts" => {
@@ -420,6 +421,7 @@ impl Reader {
}
true
}
Dest::StyleSheet => false,
Dest::ListTable => {
self.list_table_word(word, value);
true
+2 -2
View File
@@ -26,6 +26,6 @@ chrono = { workspace = true }
js-sys = "0.3"
[dev-dependencies]
hayro-syntax = "0.7.2"
hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts"] }
hayro-syntax = "0.9.0"
hayro-interpret = { version = "0.8.0", default-features = false, features = ["embed-fonts"] }
hayro-cmap = "0.1"