mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
fix(text): bound untrusted font and PSD text processing (#8768)
* fix(text): bound font paths and imported text processing Reject encoded path tricks and cap resource-heavy font and PSD text processing to keep untrusted inputs bounded. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com> * fix(text): reject percent-encoded DEL in served paths; satisfy clippy chunks_exact lint --------- Co-authored-by: Codebuff <noreply@codebuff.com>
This commit is contained in:
co-authored by
Codebuff
parent
56d7009c78
commit
96042b8e7a
+34
-5
@@ -509,19 +509,48 @@ pub fn mac_pingfang() -> Option<std::path::PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Maximum collection faces and cumulative `name` records inspected by [`face_index_for_family`].
|
||||
const MAX_COLLECTION_FACES: usize = 256;
|
||||
const MAX_NAME_RECORDS: usize = 8192;
|
||||
|
||||
/// Face index in a font file (or collection) whose family name is `family`; 0 when `family` is
|
||||
/// empty or no face matches. Never panics on malformed data.
|
||||
pub fn face_index_for_family(bytes: &[u8], family: &str) -> u32 {
|
||||
use skrifa::{MetadataProvider, raw::FileRef, string::StringId};
|
||||
use skrifa::{
|
||||
MetadataProvider,
|
||||
raw::{FileRef, types::Tag},
|
||||
string::StringId,
|
||||
};
|
||||
if family.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let Ok(FileRef::Collection(c)) = FileRef::new(bytes) else { return 0 };
|
||||
for (i, f) in c.iter().enumerate() {
|
||||
let mut seen_faces = std::collections::HashSet::new();
|
||||
let mut seen_names = std::collections::HashSet::new();
|
||||
let mut budget = MAX_NAME_RECORDS;
|
||||
for (i, f) in c.iter().take(MAX_COLLECTION_FACES).enumerate() {
|
||||
if let Some(off_bytes) = bytes.get(12 + i * 4..12 + (i + 1) * 4)
|
||||
&& let Ok(arr) = <[u8; 4]>::try_from(off_bytes)
|
||||
&& !seen_faces.insert(u32::from_be_bytes(arr))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Ok(f) = f else { continue };
|
||||
let named = [StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME].iter().any(|id| f.localized_strings(*id).any(|s| s.chars().eq(family.chars())));
|
||||
if named {
|
||||
return i as u32;
|
||||
if let Some(rec) = f.table_directory.table_records().iter().find(|r| r.tag() == Tag::new(b"name"))
|
||||
&& !seen_names.insert((rec.offset(), rec.length()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for id in [StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME] {
|
||||
for s in f.localized_strings(id) {
|
||||
let Some(rem) = budget.checked_sub(1) else {
|
||||
return 0;
|
||||
};
|
||||
budget = rem;
|
||||
if s.chars().eq(family.chars()) {
|
||||
return i as u32;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
0
|
||||
|
||||
@@ -298,6 +298,9 @@ impl FontDb {
|
||||
/// Registers every face in `blob`, under `family` when given, else under the family each
|
||||
/// face's `name` table gives.
|
||||
fn register_blob(&mut self, blob: Blob<u8>, family: Option<&str>) -> Vec<String> {
|
||||
let Some(blob) = sanitize_font_blob(blob) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let added = self.fcx.collection.register_fonts(blob, family.map(|f| FontInfoOverride { family_name: Some(f), ..Default::default() }));
|
||||
let mut names = Vec::new();
|
||||
for (id, _) in added {
|
||||
@@ -551,6 +554,65 @@ impl FontDb {
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum faces and cumulative variation axes admitted from a single font blob.
|
||||
const MAX_COLLECTION_FACES: usize = 256;
|
||||
const MAX_COLLECTION_AXES: usize = 4096;
|
||||
|
||||
/// Validates `blob` before handing it to `fontique`, deduplicating repeated TTC table-directory
|
||||
/// offsets and rejecting collections with excessive faces or variation axes.
|
||||
fn sanitize_font_blob(blob: Blob<u8>) -> Option<Blob<u8>> {
|
||||
let data = blob.as_ref();
|
||||
if data.len() > 256 * 1024 * 1024 {
|
||||
return None;
|
||||
}
|
||||
match FileRef::new(data).ok()? {
|
||||
FileRef::Font(font) => {
|
||||
if font.axes().len() > MAX_COLLECTION_AXES {
|
||||
return None;
|
||||
}
|
||||
Some(blob)
|
||||
}
|
||||
FileRef::Collection(collection) => {
|
||||
let n = usize::try_from(collection.len()).ok()?;
|
||||
if n == 0 || n > MAX_COLLECTION_FACES {
|
||||
return None;
|
||||
}
|
||||
let dir_end = 12usize.checked_add(n.checked_mul(4)?)?;
|
||||
let offsets_raw = data.get(12..dir_end)?;
|
||||
let mut unique_offsets: Vec<u32> = Vec::with_capacity(n);
|
||||
let mut total_axes = 0usize;
|
||||
for (i, chunk) in offsets_raw.as_chunks::<4>().0.iter().enumerate() {
|
||||
let off = u32::from_be_bytes(*chunk);
|
||||
if unique_offsets.contains(&off) {
|
||||
continue;
|
||||
}
|
||||
let idx = u32::try_from(i).ok()?;
|
||||
let Ok(font) = collection.get(idx) else {
|
||||
continue;
|
||||
};
|
||||
total_axes = total_axes.checked_add(font.axes().len())?;
|
||||
if total_axes > MAX_COLLECTION_AXES {
|
||||
return None;
|
||||
}
|
||||
unique_offsets.push(off);
|
||||
}
|
||||
if unique_offsets.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if unique_offsets.len() == n {
|
||||
return Some(blob);
|
||||
}
|
||||
let mut bytes = data.to_vec();
|
||||
bytes.get_mut(8..12)?.copy_from_slice(&(unique_offsets.len() as u32).to_be_bytes());
|
||||
for (slot, off) in unique_offsets.iter().enumerate() {
|
||||
let pos = 12 + slot * 4;
|
||||
bytes.get_mut(pos..pos + 4)?.copy_from_slice(&off.to_be_bytes());
|
||||
}
|
||||
Some(Blob::new(Arc::new(bytes)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Make a collection expose only the requested face without rewriting any font tables.
|
||||
/// TTC table offsets are absolute; changing its face directory preserves them verbatim.
|
||||
fn isolated_face(blob: Blob<u8>, index: u32) -> Option<Blob<u8>> {
|
||||
|
||||
@@ -597,12 +597,13 @@ impl Layouter {
|
||||
// fetches it (`served`); it joins the fallback stack once it arrives.
|
||||
crate::served::request_for_text(&ptext);
|
||||
let fallback: Vec<String> = fonts.fallback_stack().map(str::to_string).collect();
|
||||
let si0 = style_at(prange.start);
|
||||
let si_end = run_starts.partition_point(|&rs| rs < content_end).max(si0 + 1).min(out.styles.len());
|
||||
// Shapes the paragraph, each tab widened by its letter spacing in `tab_spacing` (px).
|
||||
let build = |lcx: &mut LayoutContext<RunBrush>, fcx: &mut parley::FontContext, tab_spacing: &[f32]| -> Layout<RunBrush> {
|
||||
let mut b = lcx.ranged_builder(fcx, &ptext, 1.0, false);
|
||||
// Paragraph-start style as the default (covers the direction mark and empty
|
||||
// paragraphs), then every run piece intersecting this paragraph.
|
||||
let si0 = style_at(prange.start);
|
||||
for p in style_props(&out.styles[si0], k, &fallback, si0 as u32, small_caps[si0]) {
|
||||
b.push_default(p);
|
||||
}
|
||||
@@ -612,7 +613,7 @@ impl Layouter {
|
||||
if is_box {
|
||||
b.push_default(StyleProperty::OverflowWrap(parley::style::OverflowWrap::BreakWord));
|
||||
}
|
||||
for (ri, st) in out.styles.iter().enumerate() {
|
||||
for (ri, st) in out.styles.iter().enumerate().take(si_end).skip(si0) {
|
||||
let rs = run_starts[ri];
|
||||
let (a, z) = (rs.max(prange.start), (rs + runs[ri].len).min(content_end));
|
||||
if a >= z {
|
||||
@@ -675,8 +676,7 @@ impl Layouter {
|
||||
// width divided by it (#5565). Mixed scales break at the largest, so no line
|
||||
// overflows the box.
|
||||
let break_scale = if is_box {
|
||||
let si0 = style_at(prange.start);
|
||||
(0..out.styles.len())
|
||||
(si0..si_end)
|
||||
.filter(|&ri| {
|
||||
let rs = run_starts.get(ri).copied().unwrap_or(0);
|
||||
let end = rs.saturating_add(runs.get(ri).map_or(0, |r| r.len));
|
||||
|
||||
+130
-54
@@ -435,15 +435,22 @@ fn descriptor_rect(text: &Descriptor, key: &str) -> Option<[f64; 4]> {
|
||||
(bounds.iter().all(|v| v.is_finite()) && bounds[2] > bounds[0] && bounds[3] > bounds[1]).then_some(bounds)
|
||||
}
|
||||
|
||||
const MAX_ENGINE_RUNS: usize = 65_536;
|
||||
|
||||
pub(crate) fn arr_f(v: Option<&E>) -> Vec<f64> {
|
||||
v.and_then(E::as_array).map(|a| a.iter().filter_map(E::as_f64).collect()).unwrap_or_default()
|
||||
v.and_then(E::as_array).map(|a| a.iter().take(MAX_ENGINE_RUNS).filter_map(E::as_f64).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Converts UTF-16 run lengths over `text` into UTF-8 byte lengths.
|
||||
/// Converts UTF-16 run lengths over `text` into UTF-8 byte lengths, stopping after the first run
|
||||
/// once `text` is exhausted so trailing runs past the text aren't processed.
|
||||
pub(crate) fn utf16_to_byte_lengths(text: &str, lens: &[f64]) -> Vec<usize> {
|
||||
let mut out = Vec::with_capacity(lens.len());
|
||||
let mut out = Vec::with_capacity(lens.len().min(text.len().saturating_add(1)));
|
||||
let mut chars = text.chars().peekable();
|
||||
for &l in lens {
|
||||
let mut exhausted = false;
|
||||
for &l in lens.iter().take(MAX_ENGINE_RUNS) {
|
||||
if exhausted {
|
||||
break;
|
||||
}
|
||||
let mut units = l.max(0.0) as usize;
|
||||
let mut bytes = 0;
|
||||
while units > 0 {
|
||||
@@ -452,9 +459,15 @@ pub(crate) fn utf16_to_byte_lengths(text: &str, lens: &[f64]) -> Vec<usize> {
|
||||
units = units.saturating_sub(c.len_utf16());
|
||||
bytes += c.len_utf8();
|
||||
}
|
||||
None => break,
|
||||
None => {
|
||||
exhausted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if chars.peek().is_none() {
|
||||
exhausted = true;
|
||||
}
|
||||
out.push(bytes);
|
||||
}
|
||||
out
|
||||
@@ -495,17 +508,22 @@ fn apply_engine_data(layer: &mut TextLayer, e: &E, txt: Option<&str>, k: f32) {
|
||||
|
||||
// Paragraph runs first: a character run's defaults depend on its paragraph.
|
||||
let prun = e.path(&["EngineDict", "ParagraphRun"]);
|
||||
let psheets: Vec<&E> =
|
||||
prun.and_then(|r| r.get("RunArray")).and_then(E::as_array).map(|a| a.iter().filter_map(|x| x.get("ParagraphSheet")).collect()).unwrap_or_default();
|
||||
let plens16 = arr_f(prun.and_then(|r| r.get("RunLengthArray")));
|
||||
let plens = utf16_to_byte_lengths(&ed_text, &plens16);
|
||||
let max_pruns = plens.len().max(1);
|
||||
let psheets: Vec<&E> = prun
|
||||
.and_then(|r| r.get("RunArray"))
|
||||
.and_then(E::as_array)
|
||||
.map(|a| a.iter().filter_map(|x| x.get("ParagraphSheet")).take(max_pruns).collect())
|
||||
.unwrap_or_default();
|
||||
let plens16 = &plens16[..plens16.len().min(psheets.len())];
|
||||
let empty = E::Dict(Vec::new());
|
||||
// Per paragraph run: its merged base properties and its default character style.
|
||||
let para_bases: Vec<(E, Option<&E>)> = psheets
|
||||
// Per paragraph run: its borrowed base property layers and its default character style.
|
||||
let para_bases: Vec<([Option<&E>; 2], Option<&E>)> = psheets
|
||||
.iter()
|
||||
.map(|sheet| {
|
||||
let style = named(para_set, sheet).and_then(|i| para_set.get(i));
|
||||
let base = merged(&[style.and_then(|s| s.get("Properties")), normal_para.and_then(|s| s.get("Properties"))]);
|
||||
let base = [style.and_then(|s| s.get("Properties")), normal_para.and_then(|s| s.get("Properties"))];
|
||||
let char_default = [Some(*sheet), style]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
@@ -517,15 +535,19 @@ fn apply_engine_data(layer: &mut TextLayer, e: &E, txt: Option<&str>, k: f32) {
|
||||
.iter()
|
||||
.zip(¶_bases)
|
||||
.zip(plens)
|
||||
.map(|((sheet, (base, _)), len)| ParagraphRun { len, style: para_style(Some(base), sheet.get("Properties").unwrap_or(&empty), k) })
|
||||
.map(|((sheet, (base, _)), len)| ParagraphRun { len, style: para_style(base, sheet.get("Properties").unwrap_or(&empty), k) })
|
||||
.collect();
|
||||
|
||||
// Character runs.
|
||||
let srun = e.path(&["EngineDict", "StyleRun"]);
|
||||
let ssheets: Vec<&E> =
|
||||
srun.and_then(|r| r.get("RunArray")).and_then(E::as_array).map(|a| a.iter().filter_map(|x| x.get("StyleSheet")).collect()).unwrap_or_default();
|
||||
let slens16 = arr_f(srun.and_then(|r| r.get("RunLengthArray")));
|
||||
let slens = utf16_to_byte_lengths(&ed_text, &slens16);
|
||||
let max_sruns = slens.len().max(1);
|
||||
let ssheets: Vec<&E> = srun
|
||||
.and_then(|r| r.get("RunArray"))
|
||||
.and_then(E::as_array)
|
||||
.map(|a| a.iter().filter_map(|x| x.get("StyleSheet")).take(max_sruns).collect())
|
||||
.unwrap_or_default();
|
||||
// The paragraph run each character run starts in (UTF-16 offsets).
|
||||
let para_of = |at: f64| -> Option<usize> {
|
||||
let mut end = 0.0;
|
||||
@@ -538,14 +560,14 @@ fn apply_engine_data(layer: &mut TextLayer, e: &E, txt: Option<&str>, k: f32) {
|
||||
plens16.len().checked_sub(1)
|
||||
};
|
||||
let mut at16 = 0.0;
|
||||
let bases: Vec<E> = ssheets
|
||||
let bases: Vec<[Option<&E>; 3]> = ssheets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, sheet)| {
|
||||
let own = named(style_set, sheet).and_then(|i| style_set.get(i));
|
||||
let para_default = para_of(at16).and_then(|p| para_bases.get(p)).and_then(|(_, c)| *c);
|
||||
at16 += slens16.get(i).copied().unwrap_or(0.0);
|
||||
merged(&[own, para_default, normal_style].map(|s| s.and_then(|s| s.get("StyleSheetData"))))
|
||||
[own, para_default, normal_style].map(|s| s.and_then(|s| s.get("StyleSheetData")))
|
||||
})
|
||||
.collect();
|
||||
let sdata: Vec<&E> = ssheets.iter().map(|s| s.get("StyleSheetData").unwrap_or(&empty)).collect();
|
||||
@@ -554,12 +576,12 @@ fn apply_engine_data(layer: &mut TextLayer, e: &E, txt: Option<&str>, k: f32) {
|
||||
.zip(&bases)
|
||||
.zip(&slens)
|
||||
.map(|((d, base), len)| {
|
||||
let auto = lookup(d, Some(base), "AutoKerning").and_then(E::as_bool) != Some(false);
|
||||
let kern = lookup(d, Some(base), "Kerning").and_then(E::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0) as f32;
|
||||
let auto = lookup(d, base, "AutoKerning").and_then(E::as_bool) != Some(false);
|
||||
let kern = lookup(d, base, "Kerning").and_then(E::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0) as f32;
|
||||
(*len, auto, kern)
|
||||
})
|
||||
.collect();
|
||||
layer.runs = sdata.iter().zip(&bases).zip(slens).map(|((d, base), len)| TextRun { len, style: char_style(Some(base), d, &fonts, k) }).collect();
|
||||
layer.runs = sdata.iter().zip(&bases).zip(slens).map(|((d, base), len)| TextRun { len, style: char_style(base, d, &fonts, k) }).collect();
|
||||
layer.runs = kerning_from_pairs(&layer.text, layer.char_runs(), &incoming);
|
||||
|
||||
// Shape.
|
||||
@@ -574,26 +596,11 @@ fn apply_engine_data(layer: &mut TextLayer, e: &E, txt: Option<&str>, k: f32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The dictionaries in `levels` as one: a key comes from the first level that has it.
|
||||
fn merged(levels: &[Option<&E>]) -> E {
|
||||
let mut out: Vec<(String, E)> = Vec::new();
|
||||
for level in levels.iter().flatten() {
|
||||
if let E::Dict(kv) = level {
|
||||
for (k, v) in kv {
|
||||
if !out.iter().any(|(o, _)| o == k) {
|
||||
out.push((k.clone(), v.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
E::Dict(out)
|
||||
fn lookup<'a>(run: &'a E, base: &[Option<&'a E>], key: &str) -> Option<&'a E> {
|
||||
run.get(key).or_else(|| base.iter().flatten().find_map(|b| b.get(key)))
|
||||
}
|
||||
|
||||
fn lookup<'a>(run: &'a E, base: Option<&'a E>, key: &str) -> Option<&'a E> {
|
||||
run.get(key).or_else(|| base.and_then(|b| b.get(key)))
|
||||
}
|
||||
|
||||
pub(crate) fn char_style(base: Option<&E>, d: &E, fonts: &[String], k: f32) -> CharStyle {
|
||||
pub(crate) fn char_style(base: &[Option<&E>], d: &E, fonts: &[String], k: f32) -> CharStyle {
|
||||
let g = |key: &str| lookup(d, base, key);
|
||||
let num = |key: &str| g(key).and_then(E::as_f64);
|
||||
// An f32 that a huge finite f64 overflowed to infinity is dropped too.
|
||||
@@ -658,7 +665,7 @@ pub(crate) fn char_style(base: Option<&E>, d: &E, fonts: &[String], k: f32) -> C
|
||||
s
|
||||
}
|
||||
|
||||
pub(crate) fn para_style(base: Option<&E>, d: &E, k: f32) -> ParagraphStyle {
|
||||
pub(crate) fn para_style(base: &[Option<&E>], d: &E, k: f32) -> ParagraphStyle {
|
||||
let num = |key: &str| lookup(d, base, key).and_then(E::as_f64);
|
||||
let numk = |key: &str, k: f32| num(key).map(|v| v as f32 * k).filter(|v| v.is_finite());
|
||||
ParagraphStyle {
|
||||
@@ -1239,6 +1246,12 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi
|
||||
/// own objects hold a few hundred (611 at most in the file from #7698).
|
||||
const TEMPLATE_NODES: usize = 4096;
|
||||
|
||||
/// Cumulative node and byte budgets across all run dictionaries cloned while refitting one `Txt2`
|
||||
/// object (`runs_per` and `runs_to_length`), so an edited object with a large preserved run and
|
||||
/// many output paragraphs falls back to a fresh object instead of amplifying memory.
|
||||
const MAX_REFIT_RUN_NODES: usize = 16_384;
|
||||
const MAX_REFIT_RUN_BYTES: usize = 1 << 20;
|
||||
|
||||
/// Whether `v` holds at most `budget` values, counting down `budget` (stops at the first one over).
|
||||
fn fits(v: &E, budget: &mut usize) -> bool {
|
||||
let Some(left) = budget.checked_sub(1) else { return false };
|
||||
@@ -1250,6 +1263,27 @@ fn fits(v: &E, budget: &mut usize) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `v` fits within the remaining `(nodes, bytes)` budget, deducting its nodes and string
|
||||
/// bytes as it walks.
|
||||
fn fits_budget(v: &E, nodes: &mut usize, bytes: &mut usize) -> bool {
|
||||
let Some(left) = nodes.checked_sub(1) else { return false };
|
||||
*nodes = left;
|
||||
match v {
|
||||
E::Dict(items) => items.iter().all(|(k, x)| {
|
||||
let Some(rem) = bytes.checked_sub(k.len()) else { return false };
|
||||
*bytes = rem;
|
||||
fits_budget(x, nodes, bytes)
|
||||
}),
|
||||
E::Array(items) => items.iter().all(|x| fits_budget(x, nodes, bytes)),
|
||||
E::String(s) | E::Name(s) => {
|
||||
let Some(rem) = bytes.checked_sub(s.len()) else { return false };
|
||||
*bytes = rem;
|
||||
true
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the text object `object` has a view naming one frame (`/1 /0 [ << /0 n >> ]`), as
|
||||
/// Photoshop writes it.
|
||||
fn names_one_frame(object: &E) -> bool {
|
||||
@@ -1277,7 +1311,8 @@ fn new_object(template: &E, at: usize, text: &str) -> Option<E> {
|
||||
}
|
||||
}
|
||||
if let Some(runs) = view.path(&["1", "0"]).and_then(E::as_array) {
|
||||
let fitted = runs_per(runs, ¶graphs, "1")?;
|
||||
let (mut nodes, mut bytes) = (MAX_REFIT_RUN_NODES, MAX_REFIT_RUN_BYTES);
|
||||
let fitted = runs_per(runs, ¶graphs, "1", &mut nodes, &mut bytes)?;
|
||||
view.get_mut("1")?.set("0", E::Array(fitted));
|
||||
}
|
||||
Some(object)
|
||||
@@ -1322,6 +1357,7 @@ fn edited_object(object: &E, text: &str) -> Option<E> {
|
||||
let total = utf16_len(text) as usize;
|
||||
let paragraphs: Vec<usize> = text.split_inclusive('\r').map(|p| p.encode_utf16().count()).collect();
|
||||
let E::Dict(entries) = model else { return None };
|
||||
let (mut nodes, mut bytes) = (MAX_REFIT_RUN_NODES, MAX_REFIT_RUN_BYTES);
|
||||
for (key, value) in entries.iter_mut() {
|
||||
if key.as_str() == "0" {
|
||||
*value = E::String(text.to_string());
|
||||
@@ -1330,7 +1366,11 @@ fn edited_object(object: &E, text: &str) -> Option<E> {
|
||||
let Some(runs) = value.get("0").and_then(E::as_array).filter(|r| r.iter().all(|x| matches!(x, E::Dict(_)) && x.get("1").is_some())) else {
|
||||
continue;
|
||||
};
|
||||
let fitted = if key.as_str() == "5" { runs_per(runs, ¶graphs, "1")? } else { runs_to_length(runs, total, "1")? };
|
||||
let fitted = if key.as_str() == "5" {
|
||||
runs_per(runs, ¶graphs, "1", &mut nodes, &mut bytes)?
|
||||
} else {
|
||||
runs_to_length(runs, total, "1", &mut nodes, &mut bytes)?
|
||||
};
|
||||
value.set("0", E::Array(fitted));
|
||||
}
|
||||
Some(object)
|
||||
@@ -1357,6 +1397,7 @@ fn td_edited_object(object: &E, text: &str) -> Option<E> {
|
||||
}
|
||||
let total = utf16_len(text) as usize;
|
||||
let paragraphs: Vec<usize> = text.split_inclusive('\r').map(|p| p.encode_utf16().count()).collect();
|
||||
let (mut nodes, mut bytes) = (MAX_REFIT_RUN_NODES, MAX_REFIT_RUN_BYTES);
|
||||
for (key, value) in entries.iter_mut() {
|
||||
if key.as_str() == "Text" {
|
||||
*value = E::String(text.to_string());
|
||||
@@ -1372,9 +1413,9 @@ fn td_edited_object(object: &E, text: &str) -> Option<E> {
|
||||
let fitted = match runs {
|
||||
E::Array(runs) if runs.iter().all(|x| matches!(x, E::Dict(_)) && x.get("Length").is_some()) => {
|
||||
if key.as_str() == "ParagraphRun" {
|
||||
runs_per(runs, ¶graphs, "Length")?
|
||||
runs_per(runs, ¶graphs, "Length", &mut nodes, &mut bytes)?
|
||||
} else {
|
||||
runs_to_length(runs, total, "Length")?
|
||||
runs_to_length(runs, total, "Length", &mut nodes, &mut bytes)?
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
@@ -1385,13 +1426,18 @@ fn td_edited_object(object: &E, text: &str) -> Option<E> {
|
||||
}
|
||||
|
||||
/// `runs` (`<< … /1 length >>`, the length key named `len_key`) remade with the lengths `lens`:
|
||||
/// run i keeps the content of the file's run i, else of its last one. `None` for an empty `runs`.
|
||||
fn runs_per(runs: &[E], lens: &[usize], len_key: &str) -> Option<Vec<E>> {
|
||||
/// run i keeps the content of the file's run i, else of its last one. `None` for an empty `runs`
|
||||
/// or when the cumulative `nodes` / `bytes` budget is exceeded.
|
||||
fn runs_per(runs: &[E], lens: &[usize], len_key: &str, nodes: &mut usize, bytes: &mut usize) -> Option<Vec<E>> {
|
||||
let last = runs.last()?;
|
||||
lens.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &len)| {
|
||||
let mut r = runs.get(i).unwrap_or(last).clone();
|
||||
let src = runs.get(i).unwrap_or(last);
|
||||
if !fits_budget(src, nodes, bytes) {
|
||||
return None;
|
||||
}
|
||||
let mut r = src.clone();
|
||||
r.set(len_key, E::Int(i64::try_from(len).ok()?));
|
||||
Some(r)
|
||||
})
|
||||
@@ -1400,14 +1446,18 @@ fn runs_per(runs: &[E], lens: &[usize], len_key: &str) -> Option<Vec<E>> {
|
||||
|
||||
/// `runs` (`<< … /1 length >>`, the length key named `len_key`) covering `total` UTF-16 units:
|
||||
/// the leading runs as they were, cut where `total` ends, the last kept run extended when `total`
|
||||
/// is longer. Empty runs are dropped. `None` when `runs` is empty or a length isn't a count.
|
||||
fn runs_to_length(runs: &[E], total: usize, len_key: &str) -> Option<Vec<E>> {
|
||||
/// is longer. Empty runs are dropped. `None` when `runs` is empty, a length isn't a count, or the
|
||||
/// cumulative `nodes` / `bytes` budget is exceeded.
|
||||
fn runs_to_length(runs: &[E], total: usize, len_key: &str, nodes: &mut usize, bytes: &mut usize) -> Option<Vec<E>> {
|
||||
let mut out: Vec<(E, usize)> = Vec::new();
|
||||
let mut left = total;
|
||||
for r in runs {
|
||||
let len = r.get(len_key).and_then(E::as_i64).and_then(|l| usize::try_from(l).ok())?;
|
||||
let take = len.min(left);
|
||||
if take > 0 {
|
||||
if !fits_budget(r, nodes, bytes) {
|
||||
return None;
|
||||
}
|
||||
out.push((r.clone(), take));
|
||||
left -= take;
|
||||
}
|
||||
@@ -1415,7 +1465,13 @@ fn runs_to_length(runs: &[E], total: usize, len_key: &str) -> Option<Vec<E>> {
|
||||
if left > 0 {
|
||||
match out.last_mut() {
|
||||
Some(last) => last.1 = last.1.checked_add(left)?,
|
||||
None => out.push((runs.last()?.clone(), left)),
|
||||
None => {
|
||||
let last = runs.last()?;
|
||||
if !fits_budget(last, nodes, bytes) {
|
||||
return None;
|
||||
}
|
||||
out.push((last.clone(), left));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.into_iter()
|
||||
@@ -1750,6 +1806,26 @@ pub fn build_tysh(layer: &TextLayer, dpi: f32, ink: Option<[f32; 4]>) -> Vec<u8>
|
||||
write_tysh(&TySh { transform: layer.transform, text, warp, bounds })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod run_length_safety_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn utf16_lengths_stop_after_text_is_consumed() {
|
||||
assert_eq!(utf16_to_byte_lengths("é😀", &[1.0, 2.0, 1.0, 1.0e300]), [2, 4]);
|
||||
assert_eq!(utf16_to_byte_lengths("", &[1.0, 1.0e300]), [0]);
|
||||
assert_eq!(utf16_to_byte_lengths("abc", &[0.0, 1.0, 0.0, 1.0, 1.0, 1.0e300]), [0, 1, 0, 1, 1]);
|
||||
let zero_runs = vec![0.0; MAX_ENGINE_RUNS + 1];
|
||||
assert_eq!(utf16_to_byte_lengths("a", &zero_runs).len(), MAX_ENGINE_RUNS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_run_length_array_conversion_is_bounded() {
|
||||
let values = E::Array(vec![E::Real(0.0); MAX_ENGINE_RUNS + 1]);
|
||||
assert_eq!(arr_f(Some(&values)).len(), MAX_ENGINE_RUNS);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod transform_safety_tests {
|
||||
use super::*;
|
||||
@@ -1777,17 +1853,17 @@ mod nonfinite_tests {
|
||||
#[test]
|
||||
fn non_finite_style_numbers_fall_back_to_defaults() {
|
||||
let d = parse(b"<< /FontSize 1e999 /Tracking nan /HorizontalScale inf /Kerning -inf /FillColor << /Values [1.0 nan 0.0 0.0] >> >>").unwrap();
|
||||
let s = char_style(None, &d, &[], 1.0);
|
||||
let s = char_style(&[], &d, &[], 1.0);
|
||||
let dflt = CharStyle::default();
|
||||
assert_eq!(s.size_pt, dflt.size_pt);
|
||||
assert_eq!((s.tracking, s.horizontal_scale, s.kern), (0.0, 1.0, 0.0));
|
||||
assert_eq!(s.color, Color::BLACK);
|
||||
// A finite f64 too big for f32 is dropped as well.
|
||||
let d = parse(b"<< /FontSize 1e300 /StartIndent inf >> ").unwrap();
|
||||
assert_eq!(char_style(None, &d, &[], 1.0).size_pt, dflt.size_pt);
|
||||
assert_eq!(para_style(None, &d, 1.0).start_indent_pt, 0.0);
|
||||
assert_eq!(char_style(&[], &d, &[], 1.0).size_pt, dflt.size_pt);
|
||||
assert_eq!(para_style(&[], &d, 1.0).start_indent_pt, 0.0);
|
||||
let d = parse(b"<< /FontSize 24.0 >>").unwrap();
|
||||
assert_eq!(char_style(None, &d, &[], 1.0).size_pt, 24.0);
|
||||
assert_eq!(char_style(&[], &d, &[], 1.0).size_pt, 24.0);
|
||||
}
|
||||
|
||||
/// #5645: `FontBaseline` (1 superscript, 2 subscript) is read and written back.
|
||||
@@ -1795,11 +1871,11 @@ mod nonfinite_tests {
|
||||
fn font_baseline_round_trips() {
|
||||
use photocraft_doc::text::FontBaseline;
|
||||
let d = parse(b"<< /FontBaseline 2 >>").unwrap();
|
||||
assert_eq!(char_style(None, &d, &[], 1.0).baseline, FontBaseline::Subscript);
|
||||
assert_eq!(char_style(&[], &d, &[], 1.0).baseline, FontBaseline::Subscript);
|
||||
let d = parse(b"<< /FontBaseline 1 >>").unwrap();
|
||||
assert_eq!(char_style(None, &d, &[], 1.0).baseline, FontBaseline::Superscript);
|
||||
assert_eq!(char_style(&[], &d, &[], 1.0).baseline, FontBaseline::Superscript);
|
||||
let s = CharStyle { baseline: FontBaseline::Subscript, ..Default::default() };
|
||||
let back = char_style(None, &style_sheet_data(&s, 0, 1.0), &[], 1.0);
|
||||
let back = char_style(&[], &style_sheet_data(&s, 0, 1.0), &[], 1.0);
|
||||
assert_eq!(back.baseline, FontBaseline::Subscript);
|
||||
}
|
||||
|
||||
|
||||
@@ -87,9 +87,9 @@ pub fn read_style_sheets(tysh: &[u8], dpi: f32) -> Option<PsdStyleSheets> {
|
||||
let normal_p = res.get("TheNormalParagraphSheet").and_then(E::as_i64).and_then(sheet_index).unwrap_or(0);
|
||||
let empty = E::dict();
|
||||
let base_data = sset.get(normal_c).and_then(|s| s.get("StyleSheetData"));
|
||||
let normal_char = char_style(None, base_data.unwrap_or(&empty), &fonts, k);
|
||||
let normal_char = char_style(&[], base_data.unwrap_or(&empty), &fonts, k);
|
||||
let base_props = pset.get(normal_p).and_then(|s| s.get("Properties"));
|
||||
let normal_para = para_style(None, base_props.unwrap_or(&empty), k);
|
||||
let normal_para = para_style(&[], base_props.unwrap_or(&empty), k);
|
||||
let mut out = PsdStyleSheets { normal_char, normal_para, ..Default::default() };
|
||||
for (i, s) in sset.iter().enumerate() {
|
||||
if i == normal_c {
|
||||
@@ -97,7 +97,7 @@ pub fn read_style_sheets(tysh: &[u8], dpi: f32) -> Option<PsdStyleSheets> {
|
||||
}
|
||||
let name = s.get("Name").and_then(E::as_str).unwrap_or("").to_string();
|
||||
let data = s.get("StyleSheetData").unwrap_or(&empty);
|
||||
out.char_sheets.push((i, name, char_style(base_data, data, &fonts, k)));
|
||||
out.char_sheets.push((i, name, char_style(&[base_data], data, &fonts, k)));
|
||||
}
|
||||
for (i, s) in pset.iter().enumerate() {
|
||||
if i == normal_p {
|
||||
@@ -106,8 +106,8 @@ pub fn read_style_sheets(tysh: &[u8], dpi: f32) -> Option<PsdStyleSheets> {
|
||||
let name = s.get("Name").and_then(E::as_str).unwrap_or("").to_string();
|
||||
let props = s.get("Properties").unwrap_or(&empty);
|
||||
let dss = s.get("DefaultStyleSheet").and_then(E::as_i64).and_then(sheet_index).filter(|i| *i != normal_c);
|
||||
let cs = dss.and_then(|d| sset.get(d)).map(|d| char_style(base_data, d.get("StyleSheetData").unwrap_or(&empty), &fonts, k));
|
||||
out.para_sheets.push((i, name, para_style(base_props, props, k), cs));
|
||||
let cs = dss.and_then(|d| sset.get(d)).map(|d| char_style(&[base_data], d.get("StyleSheetData").unwrap_or(&empty), &fonts, k));
|
||||
out.para_sheets.push((i, name, para_style(&[base_props], props, k), cs));
|
||||
}
|
||||
let snames = names(res.get("StyleSheetSet"));
|
||||
let pnames = names(res.get("ParagraphSheetSet"));
|
||||
|
||||
+114
-21
@@ -21,6 +21,8 @@ pub const FAUX_ITALIC_DEG: f32 = 12.0;
|
||||
pub const FAUX_BOLD_RADIUS: f32 = 0.036;
|
||||
/// Largest raster we produce (pixels), as a guard against absurd sizes.
|
||||
const MAX_PIXELS: u64 = 256 * 1024 * 1024;
|
||||
/// Cumulative coverage-pixel work budget across all colour and faux-bold passes of one render.
|
||||
const MAX_PASS_PIXELS: u64 = MAX_PIXELS * 4;
|
||||
/// Peak working bytes one rasterization may keep live. [`MAX_PIXELS`] does not bound them: the
|
||||
/// accumulator and the output surface are both `stride` deep, and a faux-bold pass keeps one more
|
||||
/// coverage buffer live, so a CMYK bold render reserves several times what a plain grey one does
|
||||
@@ -135,6 +137,54 @@ fn is_bold(layout: &TextLayout, g: &PlacedGlyph) -> bool {
|
||||
layout.styles.get(g.style as usize).is_some_and(|s| s.faux_bold) || face_bold
|
||||
}
|
||||
|
||||
fn glyph_is_placed(layout: &TextLayout, g: &PlacedGlyph, warp: Option<&Warp>) -> bool {
|
||||
let Some(face) = layout.faces.get(g.face as usize) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(font) = skrifa::FontRef::from_index(face.font.data.as_ref(), face.font.index) else {
|
||||
return false;
|
||||
};
|
||||
if font.outline_glyphs().get(GlyphId::new(g.id)).is_none() {
|
||||
return false;
|
||||
}
|
||||
if let Some(w) = warp {
|
||||
let coords: Vec<NormalizedCoord> = face.coords.iter().map(|&c| NormalizedCoord::from_bits(c)).collect();
|
||||
if matches!(w.place_glyph(glyph_center(layout, g, &font, face.size_px, &coords)), Placement::Hidden) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Distinct visible colours (`alpha > 0.0`) used by placed glyphs or decorations, each paired with
|
||||
/// whether any placed glyph of that colour uses faux/synthetic bold.
|
||||
fn visible_color_passes(layout: &TextLayout, warp: Option<&Warp>) -> Vec<(Color, bool)> {
|
||||
let mut colors: Vec<(Color, bool)> = Vec::new();
|
||||
let mut record = |c: Color, bold: bool| {
|
||||
if !(c.alpha.is_finite() && c.alpha > 0.0) {
|
||||
return;
|
||||
}
|
||||
match colors.iter_mut().find(|(existing, _)| *existing == c) {
|
||||
Some((_, b)) => *b |= bold,
|
||||
None => colors.push((c, bold)),
|
||||
}
|
||||
};
|
||||
for g in &layout.glyphs {
|
||||
let Some(st) = layout.styles.get(g.style as usize) else { continue };
|
||||
if !(st.color.alpha.is_finite() && st.color.alpha > 0.0) {
|
||||
continue;
|
||||
}
|
||||
if glyph_is_placed(layout, g, warp) {
|
||||
record(st.color, is_bold(layout, g));
|
||||
}
|
||||
}
|
||||
for d in &layout.decorations {
|
||||
let Some(st) = layout.styles.get(d.style as usize) else { continue };
|
||||
record(st.color, false);
|
||||
}
|
||||
colors
|
||||
}
|
||||
|
||||
fn draw_pass(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only_color: Option<&Color>, warp: Option<&Warp>, pass: Pass) {
|
||||
for g in &layout.glyphs {
|
||||
let st = &layout.styles[g.style as usize];
|
||||
@@ -302,6 +352,19 @@ pub fn rasterize(layout: &TextLayout, transform: &Affine, format: PixelFormat, a
|
||||
|
||||
/// [`rasterize`] with the glyph outlines bent by `warp` (Type › Warp Text).
|
||||
pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFormat, antialias: AntiAlias, warp: Option<&Warp>) -> Rendered {
|
||||
rasterize_passes(layout, transform, format, antialias, warp, true)
|
||||
}
|
||||
|
||||
/// [`rasterize_warped`] with every colour's coverage either confined to that colour's own ink
|
||||
/// (`per_colour`) or spanning the whole text, the oracle the tests compare it with (#8602).
|
||||
pub(crate) fn rasterize_passes(
|
||||
layout: &TextLayout,
|
||||
transform: &Affine,
|
||||
format: PixelFormat,
|
||||
antialias: AntiAlias,
|
||||
warp: Option<&Warp>,
|
||||
per_colour: bool,
|
||||
) -> Rendered {
|
||||
let format = PixelFormat { alpha: true, ..format };
|
||||
let rect = ink_rect_warped(layout, transform, warp);
|
||||
let (w, h) = (rect.width() as usize, rect.height() as usize);
|
||||
@@ -309,10 +372,14 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo
|
||||
if w == 0 || h == 0 || (w as u64) * (h as u64) > MAX_PIXELS {
|
||||
return Rendered { surface, rect: Rect::new(0, 0, 0, 0) };
|
||||
}
|
||||
let colors = visible_color_passes(layout, warp);
|
||||
if colors.is_empty() {
|
||||
return Rendered { surface, rect: Rect::new(0, 0, 0, 0) };
|
||||
}
|
||||
let n = format.mode.color_channels();
|
||||
let stride = n + 1;
|
||||
let bold = layout.glyphs.iter().any(|g| is_bold(layout, g));
|
||||
if !fits_raster_budget(w, h, stride, format.sample, bold, MAX_RASTER_BYTES) {
|
||||
let any_bold = colors.iter().any(|&(_, b)| b);
|
||||
if !fits_raster_budget(w, h, stride, format.sample, any_bold, MAX_RASTER_BYTES) {
|
||||
return nothing(format);
|
||||
}
|
||||
// Premultiplied accumulation.
|
||||
@@ -320,26 +387,45 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo
|
||||
return nothing(format);
|
||||
};
|
||||
let xf = Xform([1.0, 0.0, 0.0, 1.0, -rect.x0 as f64, -rect.y0 as f64]).mul(&Xform(transform.m));
|
||||
let mut colors: Vec<Color> = Vec::new();
|
||||
for st in &layout.styles {
|
||||
if !colors.contains(&st.color) {
|
||||
colors.push(st.color);
|
||||
let whole = Rect::new(0, 0, w as i32, h as i32);
|
||||
let mut pass_pixels = 0u64;
|
||||
for &(c, color_bold) in &colors {
|
||||
// Rasterise each colour only over its own glyphs (every offset of faux bold included):
|
||||
// a full-size pass per colour made text with many coloured runs cost colours x area
|
||||
// (#8602). Outside its ink a colour's coverage is zero, so the result is the same.
|
||||
let sub = if per_colour {
|
||||
let mut b = Bounds::default();
|
||||
draw_pass(layout, &xf, &mut b, Some(&c), warp, Pass::All);
|
||||
b.rect.map(rect_from_bounds).unwrap_or(Rect::EMPTY).intersect(&whole)
|
||||
} else {
|
||||
whole
|
||||
};
|
||||
if sub.is_empty() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
for c in &colors {
|
||||
let Some(mut cov) = Coverage::try_new(w, h) else {
|
||||
let (sw, sh) = (sub.width() as usize, sub.height() as usize);
|
||||
let passes = 1 + if color_bold { BOLD_OFFSETS.len() } else { 0 };
|
||||
let Some(cost) = (sw as u64).checked_mul(sh as u64).and_then(|px| px.checked_mul(passes as u64)) else {
|
||||
return nothing(format);
|
||||
};
|
||||
draw_pass(layout, &xf, &mut cov, Some(c), warp, Pass::Plain);
|
||||
let Some(total) = pass_pixels.checked_add(cost).filter(|&t| t <= MAX_PASS_PIXELS) else {
|
||||
return nothing(format);
|
||||
};
|
||||
pass_pixels = total;
|
||||
let xf = Xform([1.0, 0.0, 0.0, 1.0, -sub.x0 as f64, -sub.y0 as f64]).mul(&xf);
|
||||
let Some(mut cov) = Coverage::try_new(sw, sh) else {
|
||||
return nothing(format);
|
||||
};
|
||||
draw_pass(layout, &xf, &mut cov, Some(&c), warp, Pass::Plain);
|
||||
let Some(mut cov) = cov.finish() else {
|
||||
return nothing(format);
|
||||
};
|
||||
if bold {
|
||||
if color_bold {
|
||||
for k in 0..BOLD_OFFSETS.len() {
|
||||
let Some(mut bc) = Coverage::try_new(w, h) else {
|
||||
let Some(mut bc) = Coverage::try_new(sw, sh) else {
|
||||
return nothing(format);
|
||||
};
|
||||
draw_pass(layout, &xf, &mut bc, Some(c), warp, Pass::Bold(k));
|
||||
draw_pass(layout, &xf, &mut bc, Some(&c), warp, Pass::Bold(k));
|
||||
let Some(bold_cov) = bc.finish() else {
|
||||
return nothing(format);
|
||||
};
|
||||
@@ -348,9 +434,9 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo
|
||||
}
|
||||
}
|
||||
}
|
||||
let comps = color_in(&format, c);
|
||||
let comps = color_in(&format, &c);
|
||||
let a = c.alpha.clamp(0.0, 1.0);
|
||||
composite(&mut acc, &cov, w, stride, &comps, a, antialias);
|
||||
composite(&mut acc, w, sub, &cov, stride, &comps, a, antialias);
|
||||
}
|
||||
// Unpremultiply.
|
||||
unpremultiply(&mut acc, n, stride, format.sample);
|
||||
@@ -359,12 +445,19 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo
|
||||
Rendered { surface, rect }
|
||||
}
|
||||
|
||||
/// Composites one colour's coverage over the premultiplied accumulator, after [`shape_antialias_coverage`].
|
||||
/// A pixel's maths reads and writes only its own slots, so the rows are done in any
|
||||
/// order — one per thread — for exactly the same bytes.
|
||||
fn composite(acc: &mut [f32], cov: &[f32], width: usize, stride: usize, comps: &[f32], a: f32, antialias: AntiAlias) {
|
||||
/// Composites one colour's coverage, covering `sub` of a `width`-pixel-wide premultiplied
|
||||
/// accumulator, after [`shape_antialias_coverage`]. A pixel's maths reads and writes only its own
|
||||
/// slots, so the rows are done in any order — one per thread — for exactly the same bytes.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn composite(acc: &mut [f32], width: usize, sub: Rect, cov: &[f32], stride: usize, comps: &[f32], a: f32, antialias: AntiAlias) {
|
||||
let n = comps.len();
|
||||
let (x0, x1) = (sub.x0.max(0) as usize * stride, sub.x1.max(0) as usize * stride);
|
||||
let (y0, sw, sh) = (sub.y0.max(0) as usize, sub.width() as usize, sub.height() as usize);
|
||||
if sw == 0 || sh == 0 {
|
||||
return;
|
||||
}
|
||||
let row = |acc: &mut [f32], cov: &[f32]| {
|
||||
let Some(acc) = acc.get_mut(x0..x1) else { return };
|
||||
for (cv, px) in cov.iter().zip(acc.chunks_exact_mut(stride)) {
|
||||
let cv = shape_antialias_coverage(*cv, antialias);
|
||||
let s = cv * a;
|
||||
@@ -379,9 +472,9 @@ fn composite(acc: &mut [f32], cov: &[f32], width: usize, stride: usize, comps: &
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
acc.par_chunks_mut(width * stride).zip(cov.par_chunks(width)).for_each(|(a, c)| row(a, c));
|
||||
acc.par_chunks_mut(width * stride).skip(y0).take(sh).zip(cov.par_chunks(sw)).for_each(|(a, c)| row(a, c));
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
acc.chunks_mut(width * stride).zip(cov.chunks(width)).for_each(|(a, c)| row(a, c));
|
||||
acc.chunks_mut(width * stride).skip(y0).take(sh).zip(cov.chunks(sw)).for_each(|(a, c)| row(a, c));
|
||||
}
|
||||
|
||||
/// Divides the premultiplied accumulator back to straight colour. Each pixel stands alone.
|
||||
|
||||
+68
-10
@@ -40,25 +40,60 @@ pub struct ServedFont {
|
||||
pub fn parse_manifest(text: &str) -> (Vec<ServedFont>, Vec<String>) {
|
||||
let mut fonts = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) {
|
||||
let f: Vec<&str> = line.split(" | ").map(str::trim).collect();
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let raw_fields: Vec<&str> = trimmed.split(" | ").collect();
|
||||
let f: Vec<&str> = raw_fields.iter().map(|s| s.trim()).collect();
|
||||
let raw_file = raw_fields.get(2).copied().unwrap_or("");
|
||||
let valid_file = raw_file == f.get(2).copied().unwrap_or("") && is_relative_path(raw_file);
|
||||
match f.as_slice() {
|
||||
[family, _style, file, rest @ ..] if !family.is_empty() && is_relative_path(file) => {
|
||||
[family, _style, file, rest @ ..] if !family.is_empty() && valid_file => {
|
||||
let scripts = rest.first().map(|s| s.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect()).unwrap_or_default();
|
||||
let integrity = rest.get(3).and_then(|sha| sri(sha));
|
||||
fonts.push(ServedFont { family: (*family).to_string(), file: (*file).to_string(), scripts, integrity });
|
||||
}
|
||||
[_, _, file, ..] if !is_relative_path(file) => skipped.push(format!("{line}: the file must be a relative path inside the site")),
|
||||
_ => skipped.push(format!("{line}: expected `family | style | file | …`")),
|
||||
[_, _, _, ..] if !valid_file => skipped.push(format!("{trimmed}: the file must be a relative path inside the site")),
|
||||
_ => skipped.push(format!("{trimmed}: expected `family | style | file | …`")),
|
||||
}
|
||||
}
|
||||
(fonts, skipped)
|
||||
}
|
||||
|
||||
/// A relative path that stays inside the site: no scheme, no leading `/`, no `..` segment, no
|
||||
/// query or fragment.
|
||||
fn is_relative_path(p: &str) -> bool {
|
||||
!p.is_empty() && !p.starts_with('/') && !p.contains(['\\', '?', '#', ':']) && p.split('/').all(|seg| !seg.is_empty() && seg != "." && seg != "..")
|
||||
/// query or fragment, no ASCII control characters or whitespace, and no percent-encoded control,
|
||||
/// whitespace or separator characters.
|
||||
pub fn is_relative_path(p: &str) -> bool {
|
||||
if p.is_empty()
|
||||
|| p.starts_with('/')
|
||||
|| p.bytes().any(|b| b <= b' ' || b == 0x7f || matches!(b, b'\\' | b'?' | b'#' | b':'))
|
||||
|| !p.split('/').all(|seg| !seg.is_empty() && seg != "." && seg != "..")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let b = p.as_bytes();
|
||||
let mut decoded = Vec::with_capacity(b.len());
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
if b[i] == b'%' {
|
||||
let Some(hex) = p.get(i + 1..i + 3) else { return false };
|
||||
let Ok(byte) = u8::from_str_radix(hex, 16) else { return false };
|
||||
// Never allow encoded path syntax (including '%' for a second decode), ASCII
|
||||
// separators, or controls. High bytes are allowed only as valid UTF-8 below.
|
||||
if byte <= b' ' || byte == 0x7f || matches!(byte, b'%' | b'/' | b'\\' | b'.' | b'?' | b'#' | b':') {
|
||||
return false;
|
||||
}
|
||||
decoded.push(byte);
|
||||
i = i.saturating_add(2);
|
||||
} else {
|
||||
let Some(&byte) = b.get(i) else { return false };
|
||||
decoded.push(byte);
|
||||
}
|
||||
i = i.saturating_add(1);
|
||||
}
|
||||
std::str::from_utf8(&decoded).is_ok()
|
||||
}
|
||||
|
||||
/// The scripts a served font can be the fallback for: those the bundled fonts (Inter, which covers
|
||||
@@ -318,8 +353,31 @@ mod tests {
|
||||
assert_eq!(skipped.len(), 9, "{skipped:?}");
|
||||
}
|
||||
|
||||
/// One test for the whole queue: the catalog and the queue are process-wide, and parallel
|
||||
/// tests taking each other's requests would be flaky.
|
||||
#[test]
|
||||
fn rejects_encoded_path_separators_and_malformed_escapes() {
|
||||
for path in [
|
||||
"fonts/%2e%2e/private.ttf",
|
||||
"fonts%2fprivate.ttf",
|
||||
"fonts%5cprivate.ttf",
|
||||
"fonts/%00.ttf",
|
||||
"fonts/a%7F.ttf",
|
||||
"fonts/%20.ttf",
|
||||
"fonts/%zz.ttf",
|
||||
"fonts/%2.ttf",
|
||||
"fonts/%C0%AF.ttf",
|
||||
"fonts/%25%32%65%25%32%65/private.ttf",
|
||||
"fonts/%C3%28.ttf",
|
||||
] {
|
||||
assert!(!is_relative_path(path), "accepted {path}");
|
||||
}
|
||||
for path in ["fonts/OpenSans[wdth,wght].ttf", "fonts/é.ttf", "fonts/%C3%A9.ttf"] {
|
||||
assert!(is_relative_path(path), "rejected {path}");
|
||||
}
|
||||
let (fonts, skipped) = parse_manifest("A | Regular | fonts/%2e%2e/a.ttf\nB | Regular | fonts/%zz.ttf\n");
|
||||
assert!(fonts.is_empty());
|
||||
assert_eq!(skipped.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requests_are_limited_to_the_catalog_and_reach_layout() {
|
||||
let served = "Served Test Sans";
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
|
||||
use photocraft_doc::{DocId, LayerContent, LayerId, TextLayer};
|
||||
pub use photocraft_text::served::{ServedFont, add_families, add_fonts, parse_manifest, request_for_languages, take_requests};
|
||||
pub use photocraft_text::served::{ServedFont, add_families, add_fonts, is_relative_path, parse_manifest, request_for_languages, take_requests};
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user