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:
Dylan M. Taylor
2026-10-23 09:40:04 -04:00
committed by GitHub
co-authored by Codebuff
parent 56d7009c78
commit 96042b8e7a
10 changed files with 430 additions and 111 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "DomRect", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "Navigator"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "DomRect", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "RequestMode", "Navigator"] }
[dev-dependencies]
serde_json = { workspace = true }
+11 -10
View File
@@ -233,20 +233,21 @@ fn browser_languages() -> Vec<String> {
web_sys::window().map(|w| w.navigator().languages().iter().filter_map(|l| l.as_string()).collect()).unwrap_or_default()
}
/// GET `url` (relative to the page) and read the whole body. With `integrity` (a Subresource
/// Integrity value), the browser rejects a body whose hash differs.
/// GET `url` (a relative path on the same origin) and read the whole body. With `integrity` (a
/// Subresource Integrity value), the browser rejects a body whose hash differs.
async fn fetch_bytes(url: &str, integrity: Option<&str>) -> Result<Vec<u8>, String> {
use wasm_bindgen_futures::JsFuture;
if !served_fonts::is_relative_path(url) {
return Err("url must be a relative path".into());
}
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let window = web_sys::window().ok_or("no window")?;
let request = match integrity {
Some(sri) => {
let init = web_sys::RequestInit::new();
init.set_integrity(sri);
window.fetch_with_str_and_init(url, &init)
}
None => window.fetch_with_str(url),
};
let init = web_sys::RequestInit::new();
init.set_mode(web_sys::RequestMode::SameOrigin);
if let Some(sri) = integrity {
init.set_integrity(sri);
}
let request = window.fetch_with_str_and_init(url, &init);
let resp: web_sys::Response = JsFuture::from(request).await.map_err(js)?.dyn_into().map_err(|_| "not a Response")?;
if !resp.ok() {
return Err(format!("HTTP {}", resp.status()));
+34 -5
View File
@@ -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
+62
View File
@@ -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>> {
+4 -4
View File
@@ -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
View File
@@ -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(&para_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, &paragraphs, "1")?;
let (mut nodes, mut bytes) = (MAX_REFIT_RUN_NODES, MAX_REFIT_RUN_BYTES);
let fitted = runs_per(runs, &paragraphs, "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, &paragraphs, "1")? } else { runs_to_length(runs, total, "1")? };
let fitted = if key.as_str() == "5" {
runs_per(runs, &paragraphs, "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, &paragraphs, "Length")?
runs_per(runs, &paragraphs, "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);
}
+5 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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";
+1 -1
View File
@@ -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;