forked from 1b-agent/opencadstudio-upstream
fix: trust the probe for over-budget images; TIFF allowance; build wrapper
Review of the image wave (with empirical reproduction against the
vendored image 0.25) found the budget contract did not hold across
formats: the decoder's Limits reserve checks NATIVE bytes-per-pixel,
so a grayscale/RGB JPEG/PNG/BMP over the RGBA budget decoded anyway
and into_rgba8() then ballooned to up to 4x it — the tab death the
budgets exist to prevent. The probed dimensions already prove an
OverBudget image cannot fit, so it is now refused without decoding,
removing all dependence on per-decoder limit fidelity.
Conversely, image's TIFF codec stacks ~3x scratch buffers on top of
the reserve, so an in-budget TIFF (the flagship GIS scenario) was
refused with a self-contradictory note ('400 MB, over the 512 MB
limit'); the decoder allowance for TIFF is now 3x while tier math
stays on the real budget.
Also: the refusal message saturates instead of overflowing on crafted
headers, the image pick only matches the refusal note naming ITS file
(peek, not drain — other references' notes survive for their own
drains), and the Georgian Supplement range ends at U+2D2F (U+2D30.. is
Tifinagh, which ships no font).
Build tooling: the font files missing from dist was a trunk
finalize_dist failure (Windows file-lock during the dist wipe; staging
had them, the exit code was missed) — not a copy-dir skip; it did not
reproduce in two clean cycles. scripts/build-web.sh is now the
canonical wrapper (MSYS_NO_PATHCONV, trunk, font copy + byte
verification that hard-fails), wired into README, Trunk.toml, and the
Pages workflow.
This commit is contained in:
1 parent
ca0d7b962d
commit
58d3789bcf
8 files changed
+99
-12
No files matched your search
@@ -107,7 +107,10 @@ jobs:
|
|||||||
tool: wasm-bindgen@${{ steps.wasm-bindgen-version.outputs.version }}
|
tool: wasm-bindgen@${{ steps.wasm-bindgen-version.outputs.version }}
|
||||||
|
|
||||||
- name: Build web bundle
|
- name: Build web bundle
|
||||||
run: trunk build --locked --release --public-url /app/ --dist dist/app --html-output index.html web-app.html
|
# scripts/build-web.sh runs the same Trunk build and then verifies the
|
||||||
|
# font set landed in dist/app/fonts, so a deploy can never silently
|
||||||
|
# ship without the fonts the app fetches from fonts/*.ttf.
|
||||||
|
run: sh scripts/build-web.sh --locked --html-output index.html web-app.html
|
||||||
|
|
||||||
- name: Check out current website
|
- name: Check out current website
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -173,6 +173,14 @@ Start the development server:
|
|||||||
trunk serve
|
trunk serve
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Build the deployable bundle (runs the release Trunk build, then re-syncs and
|
||||||
|
verifies `web/fonts` in `dist/app/fonts`, so the fonts the app fetches can
|
||||||
|
never go missing from the bundle):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sh scripts/build-web.sh
|
||||||
|
```
|
||||||
|
|
||||||
## Automation
|
## Automation
|
||||||
|
|
||||||
The desktop binary supports one-shot conversion, a persistent headless server, and a client-neutral MCP endpoint for AI applications:
|
The desktop binary supports one-shot conversion, a persistent headless server, and a client-neutral MCP endpoint for AI applications:
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Trunk config for the web (wasm) build of Open CAD Studio (issue #45).
|
# Trunk config for the web (wasm) build of Open CAD Studio (issue #45).
|
||||||
#
|
#
|
||||||
# Build: trunk build --release
|
# Build: sh scripts/build-web.sh (trunk build --release + fonts sync/verify)
|
||||||
# Dev: trunk serve
|
# Dev: trunk serve
|
||||||
#
|
#
|
||||||
# Requires: rustup target add wasm32-unknown-unknown
|
# Requires: rustup target add wasm32-unknown-unknown
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Canonical web bundle build: Trunk plus a fonts sanity sync.
|
||||||
|
#
|
||||||
|
# Trunk's copy-dir pipeline always copies web/fonts in full, but its final
|
||||||
|
# "apply staging to dist" step wipes the old dist and then moves dist/app/.stage
|
||||||
|
# into place. If that wipe fails on Windows (typically a transient lock from a
|
||||||
|
# local file server or antivirus with a file open under dist), the build exits
|
||||||
|
# non-zero and leaves dist stale with .stage fully populated — easy to miss,
|
||||||
|
# and a stale bundle would 404 the fonts the app fetches from fonts/*.ttf.
|
||||||
|
# This wrapper therefore re-syncs web/fonts into dist/app/fonts after a
|
||||||
|
# successful Trunk build and verifies every font byte-for-byte, so a bundle
|
||||||
|
# produced through it can never ship without the font set.
|
||||||
|
#
|
||||||
|
# Usage: sh scripts/build-web.sh [extra trunk build args]
|
||||||
|
# local: sh scripts/build-web.sh
|
||||||
|
# CI: sh scripts/build-web.sh --locked --html-output index.html web-app.html
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
cd "$root"
|
||||||
|
|
||||||
|
# Git Bash rewrites leading-slash arguments such as --public-url /app/ into
|
||||||
|
# Windows paths; keep them verbatim. Harmless on other platforms.
|
||||||
|
MSYS_NO_PATHCONV=1
|
||||||
|
export MSYS_NO_PATHCONV
|
||||||
|
|
||||||
|
trunk build --release --dist dist/app --public-url /app/ "$@"
|
||||||
|
|
||||||
|
# Mirror the font sources over the bundle and verify the copy-dir output.
|
||||||
|
mkdir -p dist/app/fonts
|
||||||
|
cp -f web/fonts/* dist/app/fonts/
|
||||||
|
|
||||||
|
count=0
|
||||||
|
for font in web/fonts/*.ttf; do
|
||||||
|
name=$(basename "$font")
|
||||||
|
cmp -s "$font" "dist/app/fonts/$name" || {
|
||||||
|
echo "error: dist/app/fonts/$name differs from $font" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
count=$((count + 1))
|
||||||
|
done
|
||||||
|
echo "web bundle built; verified $count fonts in dist/app/fonts"
|
||||||
+7
-2
@@ -1900,8 +1900,13 @@ pub async fn pick_image_file() -> Result<(PathBuf, u32, u32), String> {
|
|||||||
let decoded = crate::scene::model::image_model::resolve_image(&reference).ok_or_else(|| {
|
let decoded = crate::scene::model::image_model::resolve_image(&reference).ok_or_else(|| {
|
||||||
// An over-budget decode refusal explains itself (dims + limit);
|
// An over-budget decode refusal explains itself (dims + limit);
|
||||||
// anything else stays the generic unsupported/corrupt message.
|
// anything else stays the generic unsupported/corrupt message.
|
||||||
crate::scene::model::image_model::take_decode_refusals()
|
// Only a note naming THIS pick explains its failure; the queue may
|
||||||
.pop()
|
// also hold refusals queued by concurrent re-resolution of other
|
||||||
|
// references, which must survive for their own drains.
|
||||||
|
crate::scene::model::image_model::decode_refusals()
|
||||||
|
.iter()
|
||||||
|
.find(|note| note.contains(short.as_str()))
|
||||||
|
.cloned()
|
||||||
.unwrap_or_else(|| format!("Unsupported or corrupt image \"{short}\""))
|
.unwrap_or_else(|| format!("Unsupported or corrupt image \"{short}\""))
|
||||||
})?;
|
})?;
|
||||||
// Entity sizing truth: the file's ORIGINAL pixel dimensions, not the
|
// Entity sizing truth: the file's ORIGINAL pixel dimensions, not the
|
||||||
|
|||||||
@@ -667,16 +667,34 @@ fn decode_with_budgets<R: std::io::BufRead + std::io::Seek>(
|
|||||||
tier_for(width, height, max_decode_bytes, max_display_pixels)
|
tier_for(width, height, max_decode_bytes, max_display_pixels)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Trust the probe, not the decoder limits: `Limits::reserve` checks the
|
||||||
|
// decoder's NATIVE bytes-per-pixel, so a grayscale/RGB JPEG/PNG/BMP over
|
||||||
|
// the RGBA budget decodes fine and then `into_rgba8()` balloons to up to
|
||||||
|
// 4x it — exactly the tab death this exists to prevent. The probed
|
||||||
|
// dimensions already prove it cannot fit; refuse without decoding.
|
||||||
|
if tier == DecodeTier::OverBudget {
|
||||||
|
return DecodeAttempt::Refused {
|
||||||
|
width: probed.map_or(0, |(width, _)| width),
|
||||||
|
height: probed.map_or(0, |(_, height)| height),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let mut reader = match open().and_then(|reader| reader.with_guessed_format()) {
|
let mut reader = match open().and_then(|reader| reader.with_guessed_format()) {
|
||||||
Ok(reader) => reader,
|
Ok(reader) => reader,
|
||||||
Err(_) => return DecodeAttempt::Failed,
|
Err(_) => return DecodeAttempt::Failed,
|
||||||
};
|
};
|
||||||
// The decoder enforces the decode budget itself (`Limits::reserve`
|
// For formats that FIT the budget, the limits still guard runaway
|
||||||
// checks the output allocation up front), so a too-large image errors
|
// allocations mid-decode. TIFF additionally stacks its own intermediate
|
||||||
// here instead of allocating first. This also RAISES the image crate's
|
// scratch buffers on top of the reserve (empirically ~3x the frame size
|
||||||
// 512 MiB default on native, where the budget is 4 GB.
|
// in image 0.25), so refusing an in-budget TIFF at face value would
|
||||||
|
// produce a note claiming e.g. "400 MB, over the 512 MB limit".
|
||||||
|
let tiff = matches!(reader.format(), Some(image::ImageFormat::Tiff));
|
||||||
let mut limits = image::Limits::default();
|
let mut limits = image::Limits::default();
|
||||||
limits.max_alloc = Some(max_decode_bytes);
|
limits.max_alloc = Some(if tiff {
|
||||||
|
max_decode_bytes.saturating_mul(3)
|
||||||
|
} else {
|
||||||
|
max_decode_bytes
|
||||||
|
});
|
||||||
reader.limits(limits);
|
reader.limits(limits);
|
||||||
let img = match reader.decode() {
|
let img = match reader.decode() {
|
||||||
Ok(img) => img,
|
Ok(img) => img,
|
||||||
@@ -747,7 +765,7 @@ fn refusal_message(path: &str, width: u32, height: u32) -> String {
|
|||||||
.file_name()
|
.file_name()
|
||||||
.map(|name| name.to_string_lossy().into_owned())
|
.map(|name| name.to_string_lossy().into_owned())
|
||||||
.unwrap_or_else(|| path.to_string());
|
.unwrap_or_else(|| path.to_string());
|
||||||
let decoded_mb = width as u64 * height as u64 * 4 / (1024 * 1024);
|
let decoded_mb = (width as u64).saturating_mul(height as u64).saturating_mul(4) / (1024 * 1024);
|
||||||
let limit_mb = MAX_DECODE_BYTES / (1024 * 1024);
|
let limit_mb = MAX_DECODE_BYTES / (1024 * 1024);
|
||||||
crate::tf!(
|
crate::tf!(
|
||||||
"\"{name}\" is {width}×{height} px and would decode to {decoded} MB, over the {limit} MB image limit of this build — it cannot be attached. Images within the limit are attached at a reduced display resolution when needed.",
|
"\"{name}\" is {width}×{height} px and would decode to {decoded} MB, over the {limit} MB image limit of this build — it cannot be attached. Images within the limit are attached at a reduced display resolution when needed.",
|
||||||
@@ -768,6 +786,17 @@ fn refusal_notes() -> &'static Mutex<Vec<String>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Take and clear every recorded decode refusal (most recent last).
|
/// Take and clear every recorded decode refusal (most recent last).
|
||||||
|
/// Peek without draining — callers that only want THEIR note (the pick
|
||||||
|
/// path) must not consume refusals queued for other references.
|
||||||
|
pub fn decode_refusals() -> Vec<String> {
|
||||||
|
refusal_notes()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|error| error.into_inner())
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take and clear — for the drains that own every queued note (the poll and
|
||||||
|
/// the open-settle paths).
|
||||||
pub fn take_decode_refusals() -> Vec<String> {
|
pub fn take_decode_refusals() -> Vec<String> {
|
||||||
let mut notes = refusal_notes().lock().unwrap_or_else(|error| error.into_inner());
|
let mut notes = refusal_notes().lock().unwrap_or_else(|error| error.into_inner());
|
||||||
std::mem::take(&mut *notes)
|
std::mem::take(&mut *notes)
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ pub fn script_of(ch: char) -> Option<Script> {
|
|||||||
0x0900..=0x097F | 0xA8E0..=0xA8FF => Script::Devanagari,
|
0x0900..=0x097F | 0xA8E0..=0xA8FF => Script::Devanagari,
|
||||||
// Georgian: Asomtavruli/Nuskhuri, Mtavruli capitals, and the legacy
|
// Georgian: Asomtavruli/Nuskhuri, Mtavruli capitals, and the legacy
|
||||||
// Supplement (khutsuri round mkhedruli).
|
// Supplement (khutsuri round mkhedruli).
|
||||||
0x10A0..=0x10FF | 0x1C90..=0x1CBF | 0x2D00..=0x2D5F => Script::Georgian,
|
0x10A0..=0x10FF | 0x1C90..=0x1CBF | 0x2D00..=0x2D2F => Script::Georgian,
|
||||||
// Hangul → always Korean; kana → always Japanese.
|
// Hangul → always Korean; kana → always Japanese.
|
||||||
0x1100..=0x11FF | 0x3130..=0x318F | 0xAC00..=0xD7A3 => Script::Korean,
|
0x1100..=0x11FF | 0x3130..=0x318F | 0xAC00..=0xD7A3 => Script::Korean,
|
||||||
0x3040..=0x30FF | 0x31F0..=0x31FF => Script::Japanese,
|
0x3040..=0x30FF | 0x31F0..=0x31FF => Script::Japanese,
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ sub /tmp/NotoArmenianMerged.ttf "U+0000-024F,U+1E00-1EFF,U+2000-206F,U+0530-058F
|
|||||||
|
|
||||||
dl /tmp/NotoGeorgian.ttf "$RAW/NotoSansGeorgian/hinted/ttf/NotoSansGeorgian-Regular.ttf"
|
dl /tmp/NotoGeorgian.ttf "$RAW/NotoSansGeorgian/hinted/ttf/NotoSansGeorgian-Regular.ttf"
|
||||||
merge /tmp/NotoGeorgian.ttf /tmp/NotoGeorgianMerged.ttf
|
merge /tmp/NotoGeorgian.ttf /tmp/NotoGeorgianMerged.ttf
|
||||||
sub /tmp/NotoGeorgianMerged.ttf "U+0000-024F,U+1E00-1EFF,U+2000-206F,U+10A0-10FF,U+1C90-1CBF,U+2D00-2D5F" georgian.ttf
|
sub /tmp/NotoGeorgianMerged.ttf "U+0000-024F,U+1E00-1EFF,U+2000-206F,U+10A0-10FF,U+1C90-1CBF,U+2D00-2D2F" georgian.ttf
|
||||||
|
|
||||||
# CJK comes from the noto-cjk repo (CFF/OTF; ttf-parser reads CFF outlines) and
|
# CJK comes from the noto-cjk repo (CFF/OTF; ttf-parser reads CFF outlines) and
|
||||||
# is split by language: Simplified Chinese, Traditional Chinese, Japanese and
|
# is split by language: Simplified Chinese, Traditional Chinese, Japanese and
|
||||||
|
|||||||
Reference in new issue
Block a user